volaro 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1840 @@
1
+ # Volaro Language Design Specification
2
+
3
+ Version 0.2 (draft)
4
+ Status: design, pre-implementation
5
+ Changelog: see section 22
6
+ File extension: `.vl`
7
+ Reference implementation language: Rust
8
+
9
+ ---
10
+
11
+ ## 1. What Volaro is
12
+
13
+ Volaro is an application language written to be generated by a model and read by a person. It targets one narrow band of software: the kind of application built from views, HTTP endpoints, outbound API calls, and user accounts. Inside that band it aims to express a working feature in roughly a fifth of the tokens a TypeScript, React, and Express stack needs, with no wiring layer and no framework glue. A project has exactly one configuration file, `volaro.toml`, and it holds only what cannot be derived from source: the project name, its version, and its dependencies (§7). Everything a framework would normally push into config — routes, schema, sessions, build steps — is a declaration in the language instead.
14
+
15
+ The design starts from a specific failure pattern. When a model writes a login flow in a general-purpose stack, it produces four files, invents a session helper, forgets `httpOnly` on the cookie, and picks a password hash from whatever was popular in its training data. None of those mistakes are hard problems. They are boilerplate problems. Volaro removes the boilerplate by promoting views, API clients, HTTP services, and authentication into first-class syntax, so there is nothing left to wire incorrectly.
16
+
17
+ ### 1.1 Goals
18
+
19
+ 1. **Token density.** A feature should cost few tokens to write and few tokens to re-read. Density comes from removing redundant delimiters and from making common patterns into declarations, not from cryptic operators.
20
+ 2. **One canonical form.** For any given construct there is exactly one way to write it. No braces-or-indentation choice, no `function` versus arrow, no import style debate. A single target form makes generation more reliable and diffs stable.
21
+ 3. **Readable by a person who has never seen it.** A developer reviewing generated Volaro should follow it without a manual. Keywords are words, not sigils. Structure is visible as shape on the page.
22
+ 4. **Secure by construction.** Password storage, session cookies, CSRF tokens, and authorization checks are compiler concerns. The insecure version is not expressible.
23
+ 5. **Errors written as repair instructions.** Diagnostics are machine-readable and each one carries a suggested patch, so a model gets a fix rather than a complaint.
24
+
25
+ ### 1.2 Non-goals
26
+
27
+ Volaro is not a systems language. There is no manual memory management, no pointer arithmetic, no unsafe escape hatch, no operator overloading, no user-defined macros, no inheritance, and no configurable code formatting. Anything outside the application band (drivers, kernels, game engines, numeric kernels) belongs in Rust, and Volaro calls into Rust through a foreign function boundary rather than growing to cover it.
28
+
29
+ ---
30
+
31
+ ## 2. Design principles
32
+
33
+ ### 2.1 Delimiters carry meaning once
34
+
35
+ Most languages encode block structure twice: braces for the compiler and indentation for the reader. Volaro encodes it once. Newline ends a statement. Indentation opens a block. Dedent closes it. Semicolons and braces do not appear in statement position at all.
36
+
37
+ The saving is not trivial. In a 200-line TypeScript module, roughly 8 to 12 percent of tokens are `{`, `}`, `;`, `(`, `)`, and `,` characters serving purely as structure. Removing the redundant half of those is free density with no loss of clarity.
38
+
39
+ ### 2.2 Declarations over instructions
40
+
41
+ The stack a model finds hardest to write correctly is the part where a declarative intent gets expressed as a sequence of imperative steps: fetch data, track loading, track error, render three ways, clean up on unmount. Volaro names the intent. A `load` block in a view declares the request and its three visual states in five lines, and the compiler emits the state machine.
42
+
43
+ The same logic applies to retries, rate limits, session rotation, and role checks. Each is a property of the thing, so each is written as a property of the thing.
44
+
45
+ ### 2.3 Inference where it is safe, annotation where it is contractual
46
+
47
+ Local variables are inferred. Function parameters, return types, record fields, and anything crossing a module or network boundary are annotated. The rule is easy to state and easy for a generator to follow: if a human on the other side of the boundary would need to know the type, write it down.
48
+
49
+ ### 2.4 Compact project map
50
+
51
+ Every project compiles to a generated `map.vks` summary: every type, view, route, outbound endpoint, and role in the codebase, one line each. A model editing a 40-file project reads the map, not the files. On a mid-sized app the map runs about 300 lines against 6,000 lines of source, which changes what fits in a context window.
52
+
53
+ ---
54
+
55
+ ## 3. Lexical structure
56
+
57
+ ### 3.1 Source encoding
58
+
59
+ Source files are UTF-8. A byte-order mark at the start of a file is an error. Line endings are `\n`; a `\r\n` file is accepted by the reader and normalized by the formatter.
60
+
61
+ ### 3.2 Indentation
62
+
63
+ Indentation is two spaces per level. Tabs in leading whitespace are a hard error with no configuration option, which removes an entire category of mixed-whitespace bug.
64
+
65
+ The lexer maintains an indent stack. At the first non-whitespace token of a line it compares the line's indent width against the top of the stack and emits `INDENT`, one or more `DEDENT`, or nothing. A line whose indent matches no level on the stack is an error.
66
+
67
+ Inside an open bracket pair (`(`, `[`, `{`), newlines and indentation produce no tokens. This allows multi-line argument lists and literals without indentation rules interfering.
68
+
69
+ ### 3.3 Comments
70
+
71
+ ```vl
72
+ # line comment, runs to end of line
73
+ ## doc comment, attaches to the following declaration
74
+ ```
75
+
76
+ There is no block comment form. Editors comment ranges line by line, and a single form keeps nesting rules out of the lexer.
77
+
78
+ ### 3.4 Identifiers
79
+
80
+ Identifiers match `[a-z_][a-z0-9_]*` for values, functions, fields, and modules. Types match `[A-Z][A-Za-z0-9]*`, except the built-in primitives, which are lowercase words (`int`, `str`, `bool`).
81
+
82
+ **Components may be either case, and the case carries meaning.** A lowercase component is a *primitive element* — the layout and input set in `std.ui` (`col`, `row`, `stack`, `grid`, `text`, `img`, `button`, `input`, `link`, `spacer`), which have no children beyond what they render and are the leaves and boxes of a tree. An uppercase component is a *user or library view* declared with `view` (`PostCard`, `Spinner`, `Avatar`). Reading a view body, the case tells you whether a line is layout or a call into another component, which is the distinction that matters when scanning a tree.
83
+
84
+ A project shadowing a primitive keeps the lowercase name (§8.2). Case otherwise carries meaning as usual, so a reader knows what a bare name refers to without looking it up.
85
+
86
+ ### 3.5 Keywords
87
+
88
+ **Reserved.** These are never identifiers, in any position:
89
+
90
+ ```
91
+ and as auth break catch derive else err false
92
+ fn for if in let match mut nil not
93
+ on or par return skip spawn true try type
94
+ use while
95
+ ```
96
+
97
+ Twenty-nine reserved words.
98
+
99
+ **Contextual.** These are keywords only as the head of a declaration or of a block that expects them, and are ordinary identifiers everywhere else. `list` is the clearest case: `list posts as p key:p.id` is a view statement, while `list<Post>` is a type and `posts.list()` is a method call.
100
+
101
+ ```
102
+ api error errors index key list load model none
103
+ pending prefix public role service session state theme view
104
+ ```
105
+
106
+ A contextual keyword is recognised by position, so a field named `state` or a
107
+ function named `load` stays legal. Everything else, including all layout
108
+ components, lives in the standard library.
109
+
110
+ ### 3.6 Literals
111
+
112
+ ```vl
113
+ 42 # int
114
+ 1_000_000 # int, underscores ignored
115
+ 3.14 # float
116
+ 0xff 0b1010 # int, alternate bases
117
+ "hello" # str
118
+ "hi {name}" # str with interpolation
119
+ """
120
+ multi-line
121
+ """ # str, leading indent stripped to block level
122
+ true false # bool
123
+ nil # absence
124
+ 10s 500ms 30d # duration
125
+ [1, 2, 3] # list
126
+ {a: 1, b: 2} # record or map, depending on inferred type
127
+ 0..10 # range, end exclusive
128
+ 0..=10 # range, end inclusive
129
+ ```
130
+
131
+ Interpolation takes a full expression: `"total {price * qty}"`. Escapes are `\n`, `\t`, `\\`, `\"`, `\{`, `\u{1F600}`.
132
+
133
+ Durations are a distinct literal type rather than an int, so `ttl 30` is a type error and `ttl 30d` is not. Timeouts and expiry windows are the two places where unit confusion causes real outages.
134
+
135
+ ### 3.7 Separators
136
+
137
+ A newline separates items in block position. A comma separates items on one line. Both forms are accepted in list and record literals and in argument lists:
138
+
139
+ ```vl
140
+ let ports = [80, 443, 8080]
141
+
142
+ let ports = [
143
+ 80
144
+ 443
145
+ 8080
146
+ ]
147
+ ```
148
+
149
+ Trailing commas are a syntax error, since the newline form already handles clean diffs.
150
+
151
+ ---
152
+
153
+ ## 4. Types
154
+
155
+ ### 4.1 Primitives
156
+
157
+ | Type | Notes |
158
+ |---|---|
159
+ | `int` | 64-bit signed |
160
+ | `float` | IEEE 754 double |
161
+ | `str` | UTF-8, immutable |
162
+ | `bool` | |
163
+ | `bytes` | immutable byte string |
164
+ | `time` | instant, UTC, nanosecond resolution |
165
+ | `dur` | duration, produced by duration literals |
166
+ | `uuid` | RFC 9562 UUID |
167
+ | `json` | dynamically shaped value, checked at use |
168
+ | `nil` | the unit of absence, inhabits `T?` |
169
+
170
+ There is no `char`, no unsigned family, and no width variants. Numeric width is a systems concern, and code needing it uses the Rust boundary.
171
+
172
+ ### 4.2 Type constructors
173
+
174
+ ```vl
175
+ T? # optional: T or nil
176
+ list<T>
177
+ map<K, V>
178
+ set<T>
179
+ T! # fallible: T or the standard err type
180
+ T!E # fallible: T or E
181
+ fn(int, str) bool # function type
182
+ ```
183
+
184
+ Optionals do not nest. `T??` is a type error, which removes a class of confusion that costs nothing to forbid.
185
+
186
+ ### 4.3 Records
187
+
188
+ ```vl
189
+ type User
190
+ id uuid
191
+ email str
192
+ name str?
193
+ role Role = member
194
+ created_at time
195
+ ```
196
+
197
+ Fields are ordered and immutable after construction. Records are **nominally typed and structurally compared**: two record types with identical fields are still different types and never interchangeable, but two values of the *same* type are equal when their fields are equal, with no identity or reference semantics. Nominal typing is what makes `distinct` (§4.5) meaningful; structural equality is what makes records usable as map keys and in tests. A field with a default may be omitted at construction. Construction uses record literal syntax with the type name:
198
+
199
+ ```vl
200
+ let u = User{email: "ada@example.com", created_at: now()}
201
+ ```
202
+
203
+ ### 4.4 Unions
204
+
205
+ ```vl
206
+ type Role = admin | member | guest
207
+
208
+ type Shape =
209
+ circle(r float)
210
+ rect(w float, h float)
211
+ ```
212
+
213
+ Bare variants take no payload and compare by identity. Payload variants construct as calls: `circle(2.0)`. Union names in pattern position do not need qualification, so `match role` matches on `admin`, not `Role.admin`.
214
+
215
+ ### 4.5 Aliases and newtypes
216
+
217
+ ```vl
218
+ type Email = str # alias, freely interchangeable
219
+ type UserId = uuid distinct # newtype, requires explicit conversion
220
+ ```
221
+
222
+ `distinct` exists because passing an order ID where a user ID belongs is the most common wrong-argument bug in application code, and the type system already has the machinery to prevent it.
223
+
224
+ ### 4.6 Inference rules
225
+
226
+ Local bindings infer from their initializer. Function signatures never infer. Empty collection literals require an annotation:
227
+
228
+ ```vl
229
+ let names = [] # error: cannot infer element type
230
+ let names list<str> = [] # fine
231
+ ```
232
+
233
+ Inference is local and unidirectional. There is no global constraint solving, no return-type polymorphism driven by call site, and no implicit numeric conversion. `1 + 1.0` is a type error; write `1.0 + 1.0` or `int_to_float(1) + 1.0`.
234
+
235
+ ---
236
+
237
+ ## 5. Expressions and statements
238
+
239
+ ### 5.1 Bindings
240
+
241
+ ```vl
242
+ let name = "Ada"
243
+ let mut count = 0
244
+ count = count + 1
245
+ count += 1
246
+ ```
247
+
248
+ Bindings are immutable by default. `mut` is required for reassignment. Shadowing in the same scope is an error, since a generator that shadows accidentally produces code a reviewer misreads.
249
+
250
+ ### 5.2 Operators
251
+
252
+ Precedence, tightest first:
253
+
254
+ | Level | Operators | Associativity |
255
+ |---|---|---|
256
+ | 1 | `.` `()` `[]` `?` | left |
257
+ | 2 | `not` unary `-` | right |
258
+ | 3 | `*` `/` `%` | left |
259
+ | 4 | `+` `-` | left |
260
+ | 5 | `..` `..=` | none |
261
+ | 6 | `<` `<=` `>` `>=` | none |
262
+ | 7 | `==` `!=` | none |
263
+ | 8 | `and` | left |
264
+ | 9 | `or` | left |
265
+ | 10 | `??` | right |
266
+ | 11 | `\|>` | left |
267
+
268
+ `and` and `or` short-circuit. Comparison operators do not chain, so `a < b < c` is a syntax error with a suggested fix of `a < b and b < c`.
269
+
270
+ ### 5.3 Pipeline
271
+
272
+ ```vl
273
+ let top = posts
274
+ |> filter(fn(p) p.published)
275
+ |> sort_by(fn(p) p.score)
276
+ |> take(10)
277
+ ```
278
+
279
+ `x |> f(a)` means `f(x, a)`. The pipeline exists because deeply nested calls are where generated code most often loses track of parentheses, and the linear form reads the way the data flows.
280
+
281
+ ### 5.4 Closures
282
+
283
+ ```vl
284
+ fn(p) p.published # expression body
285
+ fn(a int, b int) int # annotated
286
+ a + b # block body
287
+ ```
288
+
289
+ Closures capture by value. A captured `mut` binding is captured as a copy, so a closure never mutates its defining scope. Shared mutable state goes through explicit reference types in `std.sync`.
290
+
291
+ ### 5.5 Conditionals
292
+
293
+ `if` is an expression. Both forms exist, and the formatter picks between them by whether the result fits on one line:
294
+
295
+ ```vl
296
+ let label = if score > 50 "pass" else "fail"
297
+
298
+ if user.role == admin
299
+ grant_all()
300
+ else if user.verified
301
+ grant_basic()
302
+ else
303
+ deny()
304
+ ```
305
+
306
+ An `if` used as an expression requires an `else`. An `if` used as a statement does not.
307
+
308
+ ### 5.6 Match
309
+
310
+ ```vl
311
+ let msg = match shape
312
+ circle(r) -> "circle of radius {r}"
313
+ rect(w, h) if w == h -> "square of {w}"
314
+ rect(w, h) -> "{w} by {h}"
315
+
316
+ match response.status
317
+ 200..=299 -> ok(response.body)
318
+ 404 -> err(not_found)
319
+ _ -> err(unexpected(response.status))
320
+ ```
321
+
322
+ Match is exhaustive. A missing case is a compile error listing the uncovered variants by name. Guards use `if` after the pattern. `_` matches anything and binds nothing.
323
+
324
+ Patterns destructure records and lists:
325
+
326
+ ```vl
327
+ match event
328
+ Click{x, y} -> move_to(x, y)
329
+ Key{code: "Escape"} -> close()
330
+ _ -> skip
331
+ ```
332
+
333
+ ### 5.7 Loops
334
+
335
+ ```vl
336
+ for post in posts
337
+ render(post)
338
+
339
+ for i in 0..10
340
+ emit(i)
341
+
342
+ for k, v in headers
343
+ write("{k}: {v}")
344
+
345
+ while queue.len() > 0
346
+ handle(queue.pop())
347
+ ```
348
+
349
+ `break` exits. `skip` advances to the next iteration. Neither takes a label; nested loops needing early exit extract a function, which is the clearer shape anyway.
350
+
351
+ ### 5.8 Functions
352
+
353
+ ```vl
354
+ ## Returns the display name, falling back to the email local part.
355
+ fn display_name(u User) str
356
+ u.name ?? u.email.split("@")[0]
357
+
358
+ fn retry_count(kind str = "default") int
359
+ match kind
360
+ "aggressive" -> 5
361
+ _ -> 2
362
+ ```
363
+
364
+ The final expression of a body is the return value. `return` exits early and is otherwise unnecessary. Parameters with defaults follow parameters without. Named arguments at call sites are allowed for any parameter and required for `bool` parameters, because `create(true, false)` tells a reviewer nothing.
365
+
366
+ ---
367
+
368
+ ## 6. Errors
369
+
370
+ ### 6.1 The fallible marker
371
+
372
+ A function that fails declares it in the return type:
373
+
374
+ ```vl
375
+ fn parse_port(s str) int!ParseErr
376
+ let n = to_int(s)?
377
+ if n < 1 or n > 65535
378
+ return err(out_of_range(n))
379
+ n
380
+ ```
381
+
382
+ `T!` uses the standard `Err` record. `T!E` uses a declared union. There is no exception mechanism, no panic in user code, and no way to ignore a fallible result silently: an unused `T!` value is a compile error.
383
+
384
+ ### 6.2 Propagation and recovery
385
+
386
+ ```vl
387
+ let user = load(id)? # propagate to caller
388
+ let user = load(id) ?? guest_user # fall back to a value
389
+
390
+ let user = try load(id)
391
+ catch not_found
392
+ create_guest()
393
+ catch e
394
+ log.error("load failed", e)
395
+ return err(e)
396
+ ```
397
+
398
+ `?` requires the enclosing function to be fallible with a compatible error type. `??` takes a value of the success type. `try`/`catch` matches on the error union with the same exhaustiveness rules as `match`.
399
+
400
+ **A handled error does not fall through.** When a `catch` arm runs, the statements after the `try` block do **not** execute — the `try` statement is complete. This is the opposite of the usual imperative reading and it is chosen deliberately: the fall-through version produced a login form that showed an error message *and* redirected to the success page, which was the single most common bug in early Volaro examples.
401
+
402
+ ```vl
403
+ try auth.sign_in(email: email, password: password)
404
+ catch invalid_credentials
405
+ error = "Wrong email or password." # no `return` needed
406
+ catch e
407
+ error = "Something went wrong."
408
+ route.to("/") # reached only when nothing was caught
409
+ ```
410
+
411
+ In expression position both arms must produce a value of the same type, and the block's value is whichever arm ran:
412
+
413
+ ```vl
414
+ let user = try load(id)
415
+ catch not_found
416
+ create_guest()
417
+ ```
418
+
419
+ Code that must run whether or not an error was caught goes **before** the `try`, or
420
+ uses `spawn` (§12) for fire-and-forget; there is no fall-through and no keyword to
421
+ opt into it.
422
+
423
+ ### 6.3 The standard error record
424
+
425
+ ```vl
426
+ type Err
427
+ code str
428
+ message str
429
+ cause Err?
430
+ fields map<str, json>
431
+ ```
432
+
433
+ `code` is a stable, machine-readable identifier. `message` is human text. `fields` carries structured context so a log line does not need string formatting to stay searchable.
434
+
435
+ **Three spellings, three meanings**, which are distinguished by case and position and are worth stating once:
436
+
437
+ | Spelling | What it is |
438
+ |---|---|
439
+ | `Err` | the record type above — a value's *type* |
440
+ | `err(...)` | the constructor: wraps a variant or an `Err` into a failed `T!`, as in `return err(not_found("no such post"))` |
441
+ | `err` | the reserved word, valid only as a `catch` binding or a match pattern head |
442
+
443
+ An `Err` is never constructed with record-literal syntax (`Err{...}`); `err(...)` is the only way to produce one, so every failure has a single construction site.
444
+
445
+ ---
446
+
447
+ ## 7. Modules
448
+
449
+ A file is a module and its path is its name. There is no export keyword. Names beginning with `_` are private to the file and everything else is public, which means visibility is readable from the name alone with no second place to check.
450
+
451
+ ```vl
452
+ use std.http
453
+ use std.time { now, Duration }
454
+ use ./models { User, Role }
455
+ use ../shared/format
456
+ ```
457
+
458
+ `std.*` resolves to the standard library. `./` and `../` resolve relative to the current file. A bare name resolves to a dependency declared in `volaro.toml`. Circular imports are an error reported with the full cycle.
459
+
460
+ A module holds **declarations only** — `use`, `type`, `model`, `fn`, `view`, `service`, `api`, `auth`, `theme`. There is no module-level value binding: `let x = …` at file scope is a syntax error. This keeps module load free of ordering and side-effect questions, and there is exactly one place a name at file scope can come from. Where a value is needed: configuration (limits, base URLs, tokens) lives in `volaro.toml` or `env`; a constant used by one `view` or `fn` is a `let` inside it; a value shared across a module is a zero-parameter `fn` (`fn tabs() list<Tab> …`, called as `tabs()`); a set of named cases is a `type` with variants. If real use shows a genuine need for a shared literal that none of these fit, a restricted module `let` (literal or const-expression right-hand side, evaluated once) can be added without breaking anything — it is left out until then.
461
+
462
+ Project layout:
463
+
464
+ ```
465
+ volaro.toml
466
+ src/
467
+ main.vl
468
+ auth.vl
469
+ models.vl
470
+ views/
471
+ Feed.vl
472
+ UserCard.vl
473
+ ```
474
+
475
+ ---
476
+
477
+ ## 8. Views
478
+
479
+ Views are the first of three domain constructs. A view declares a component: its inputs, its local state, its data dependencies, and its rendered tree.
480
+
481
+ ### 8.1 Shape
482
+
483
+ ```vl
484
+ view UserCard(user User, compact bool = false)
485
+ state expanded = false
486
+ derive initials = (user.name ?? user.email) |> split(" ") |> map(first_char) |> join("")
487
+
488
+ col gap:12 pad:16 radius:8 bg:surface
489
+ row gap:8 align:center
490
+ Avatar(src: user.avatar, fallback: initials, size: 40)
491
+ col
492
+ text user.name size:16 weight:600
493
+ text user.email size:13 color:muted
494
+ if expanded and not compact
495
+ text user.bio ?? "No bio yet." size:14
496
+ button (if expanded "Hide details" else "Show details") variant:ghost
497
+ on tap
498
+ expanded = not expanded
499
+ ```
500
+
501
+ Props are the parameter list. `state` declares reactive local state; assignment to it schedules a re-render. `derive` declares a value recomputed when its dependencies change, and the compiler works out the dependencies from the expression, so there is no dependency array to get wrong.
502
+
503
+ ### 8.2 Elements
504
+
505
+ An element is a call in statement position with attribute syntax:
506
+
507
+ ```
508
+ Name attr:value attr:value
509
+ children...
510
+ ```
511
+
512
+ Attribute values are expressions. `attr:value` with no comma is the element form; the ordinary call form `Name(a: 1)` is used in expression position, such as inside a list. Both build the same node.
513
+
514
+ Layout components (`col`, `row`, `stack`, `grid`, `text`, `img`, `button`, `input`, `link`, `spacer`) live in `std.ui` and are imported into every view automatically. They are ordinary components with no compiler privileges, so a project replaces them by shadowing the import. `dialog` and `tabs` / `tab` (§8.8) also live in `std.ui`, but have required structure the compiler checks and behaviour the compiler emits.
515
+
516
+ ### 8.3 Lists
517
+
518
+ ```vl
519
+ list posts as p key:p.id
520
+ PostCard(post: p)
521
+ ```
522
+
523
+ `key` is required. A list without a stable key is a compile error, which removes the single most common reconciliation bug in component frameworks.
524
+
525
+ ### 8.4 Data loading
526
+
527
+ ```vl
528
+ view Feed(topic str)
529
+ load posts = blog.list_posts(topic: topic)
530
+ pending
531
+ Spinner()
532
+ error e
533
+ col gap:8
534
+ text "Could not load posts." color:danger
535
+ button "Retry" on tap: posts.reload()
536
+
537
+ list posts as p key:p.id
538
+ PostCard(post: p)
539
+ ```
540
+
541
+ `load` binds a value and declares its pending and error rendering in the same block. Inside the success region the binding is the unwrapped value, not a wrapper, so `posts` is a `list<Post>` with no `.data` access and no null check. The binding also exposes `.reload()` and `.loading` for refresh cases.
542
+
543
+ The compiler emits request deduplication, cancellation on unmount, and a cache keyed on the call arguments. Re-entering `Feed` with the same `topic` within the cache window does not refetch.
544
+
545
+ ### 8.5 Lifecycle and effects
546
+
547
+ ```vl
548
+ on mount
549
+ analytics.view("feed", topic)
550
+
551
+ on change topic
552
+ scroll_to_top()
553
+
554
+ on unmount
555
+ ws.close()
556
+ ```
557
+
558
+ Effects run after the render that triggered them. `on change` takes one or more bindings and fires when any of them changes by value.
559
+
560
+ `spawn` is legal in a view body, including inside an event handler. Unlike `load` (§8.4), a spawned task is not cancelled when the view unmounts: it runs to completion, and a failure logs without affecting the view.
561
+
562
+ ### 8.6 Slots
563
+
564
+ ```vl
565
+ view Panel(title str, body slot)
566
+ col gap:8
567
+ text title weight:600
568
+ body
569
+
570
+ # call site
571
+ Panel(title: "Settings")
572
+ SettingsForm()
573
+ ```
574
+
575
+ A `slot` parameter receives the element's children. One slot per component keeps composition obvious; multiple named regions use ordinary component props instead.
576
+
577
+ ### 8.7 Styling
578
+
579
+ Attributes cover layout, spacing, color, typography, and border. A project may
580
+ declare one conventional `theme.vl`; its nested categories replace Tailwind's
581
+ owned scales and emit `--vl-<category>-<name>` properties:
582
+
583
+ ```vl
584
+ theme
585
+ color
586
+ bg "#fff" dark "#0b0b0c"
587
+ fg "#111" dark "#f2f2f3"
588
+ accent "#2563eb" dark "#2563eb"
589
+ space xs 4 sm 8 md 16 lg 24
590
+ radius sm 4 md 8 lg 16
591
+ font sans "system-ui, sans-serif" mono "ui-monospace, monospace"
592
+ screen sm 640 md 768 lg 1024
593
+ motion reduce respect
594
+ ```
595
+
596
+ Numbers in `gap:` / `pad:` remain pixels; a name such as `gap:sm` resolves to
597
+ `theme.space.sm`. `color:muted` resolves to `var(--vl-color-muted)`. Dark values
598
+ apply through both `prefers-color-scheme` and `html[data-theme="dark"]`.
599
+
600
+ `recipe button`, `recipe input`, and `recipe card` declare literal Tailwind
601
+ classes as one `base`, orthogonal axes, and defaults. `card` is a grouping
602
+ `<div>` with a child slot. Interactive recipes require a `focus-visible:`
603
+ treatment and motion utilities require a `motion-reduce:` counterpart:
604
+
605
+ ```vl
606
+ recipe button
607
+ base "rounded-md focus-visible:outline focus-visible:outline-accent"
608
+ variant primary "bg-accent text-white"
609
+ default variant:primary
610
+
611
+ button "Save" variant:primary class:"w-full sm:w-auto"
612
+ ```
613
+
614
+ `class:` is the only escape hatch and must be one complete, non-interpolated
615
+ literal. It is additive: it may not share a property group with the resolved
616
+ recipe, carry `!important`, or overlap a recipe with an inline visual attr.
617
+ Class-token order never decides precedence. Runtime compatibility rules occupy
618
+ a lower cascade layer, generated utilities a later layer, and inline attrs stay
619
+ highest only on recipe-less elements. Every requested utility must emit a
620
+ parsed CSS selector or the build fails `E-CLASS-NO-CSS`; `group` / `peer`
621
+ markers are the sole bounded exemption. The complete diagnostics and conflict
622
+ groups are normative in the Tailwind-backed styling design contract.
623
+
624
+ ### 8.8 Accessible primitives
625
+
626
+ Two `std.ui` primitives carry accessibility by construction: an inaccessible dialog or tab set is not expressible, the same property §1.1 goal 4 gives security.
627
+
628
+ **`dialog`** — a modal dialog.
629
+
630
+ ```vl
631
+ if confirming
632
+ dialog title:"Discard changes?" on dismiss: confirming = false
633
+ text "This cannot be undone." color:muted
634
+ row gap:8 justify:end
635
+ button "Cancel" variant:ghost on tap: confirming = false
636
+ button "Discard" variant:danger on tap: discard()
637
+ ```
638
+
639
+ `title` is required and becomes the accessible name. `on dismiss` is required and runs on `Escape`, on a backdrop click, and on the close affordance. The compiler emits: a portal above the page, `role="dialog"` with `aria-modal`, the rest of the page made `inert`, focus moved into the dialog and trapped there while it is open, and focus restored to the previously focused element when it closes.
640
+
641
+ A dialog is shown by rendering it — an `if` that includes `dialog`, as above — and hidden by not rendering it. **There is no `open` attribute.** A closed dialog is absent from the tree, so a control inside it cannot be reached, focused, activated, or found while it is closed; "is the dialog open" and "is its subtree present" cannot drift apart.
642
+
643
+ **`tabs` / `tab`** — a tab set. `tab` is valid only as a direct child of `tabs`.
644
+
645
+ ```vl
646
+ tabs bind:tab
647
+ tab "overview" label:"Overview"
648
+ text "Overview panel"
649
+ tab "changelog" label:"Changelog"
650
+ text "Changelog panel"
651
+ ```
652
+
653
+ `bind:tab` is a two-way binding to a `state` holding the selected tab id (§8.2 `bind`); the explicit form is `tabs selected:<id> on select t: …`. Each `tab` takes an id — the first positional value — and a required `label`. The compiler emits `role="tablist"` / `"tab"` / `"tabpanel"`, `aria-selected`, `aria-controls` / `aria-labelledby` wiring, a roving `tabindex`, and `Arrow` / `Home` / `End` key handling. Only the selected tab's panel is in the tree.
654
+
655
+ **Scope.** This is two primitives, added because two real features (`corpus/views/11-modal`, `12-tabs`) could not otherwise be expressed accessibly. It is **not** a component library. `menu`, `tooltip`, `combobox`, `disclosure` and the rest are not part of the language; each is added only when a feature demands it, the same evidence-first discipline the rest of the toolchain follows. Raw `role` / `aria-*` / `tabindex` are not an attribute surface — a primitive carries them or they are absent.
656
+
657
+ ### 8.9 Accessible inputs
658
+
659
+ `input` is accessible by construction, the same way `dialog` and `tabs` are: **an `input` with no programmatic label is not expressible.** Every `input` must carry an accessible name through exactly one of:
660
+
661
+ ```vl
662
+ col gap:4
663
+ input label:"Email" bind:email type:email help:"We never share it." error:email_error
664
+ input aria_label:"Search posts" bind:q type:search
665
+ ```
666
+
667
+ - **`label:<text>`** — a visible label. The compiler emits a `<label for=…>` bound to the control by a generated id (the compiler owns both ends, so the pair cannot drift or be forgotten) and the text is the accessible name. Help and error text are rendered as siblings, never inside the label, so the name stays exactly the label text.
668
+ - **`aria_label:<text>`** — an accessible name with **no** visible label, for a control whose purpose is already clear from adjacent visible content (a search field beside a search icon). Emits `aria-label`.
669
+
670
+ **Exactly one, and never empty.** Giving both `label:` and `aria_label:` on one `input` is `E-INPUT-NAME-DUP` (a visible label plus a silent `aria-label` override is ambiguous — keep the visible one). A `label:` / `aria_label:` whose value is a statically empty or whitespace-only string is `E-INPUT-NAME`, the same as no name at all. A name that is a non-empty expression at build time but evaluates to empty / whitespace at **render** time raises `render.empty_input_name` — a control is never rendered without a name.
671
+
672
+ **One id per rendered instance.** The generated `for` / `id` / `aria-describedby` ids are allocated per *rendered field*, not per source site, so a component used many times or a keyed-list row each gets its own label / help / error ids; every `<label>` activates its own control and every `aria-describedby` resolves within its own field. The links are rebuilt from one base each render, so they stay consistent across insertion and reordering. (The id *strings* are not stable across re-renders — like per-row handlers in a keyed list, they are refreshed each pass; the accessible name and the described text are what is stable.)
673
+
674
+ `placeholder:` is **not** a label. It vanishes on the first keystroke, is exempt from contrast rules, and is skipped by some assistive tech. An `input` that has only `placeholder:` — or neither — is `E-INPUT-NAME`, an error, with the fix naming `label:` / `aria_label:`. `placeholder:` remains legal as a *supplement* to a real name.
675
+
676
+ Two optional association attributes, both wired by the compiler so the author cannot forget the link:
677
+
678
+ - **`help:<text>`** — persistent helper text, rendered after the control and referenced from the control's `aria-describedby`.
679
+ - **`error:<text?>`** — a validation message. The expression is `nil` / empty while the field is valid. When it holds a message the compiler renders it, adds it to `aria-describedby`, and sets `aria-invalid="true"` on the control; when it is empty the live error region remains present but empty, without an error association or `aria-invalid` (§8.12). Typically a `derive` (§8.1), as in `corpus/views/08-validated-form`.
680
+
681
+ **Scope.** This slice is the `input` primitive: its name, its help text, and its error association / invalid state. Live error/status semantics are defined in §8.12; page titles and heading/landmark structure are defined in §8.11. As with §8.8, raw `aria-*` is not an attribute surface: the primitive carries these or they are absent.
682
+
683
+ ---
684
+
685
+ ### 8.10 Accessible images
686
+
687
+ `img` is accessible by construction, the same way `input` is (§8.9): **an `img` with no declared alternative is not expressible.** Every `img` states its alternative through exactly one form of a single `alt:` attribute:
688
+
689
+ ```vl
690
+ col gap:8
691
+ img src:hero alt:"The team on stage at the launch"
692
+ img src:user.avatar_url alt:"{user.name}'s profile photo"
693
+ img src:"divider.svg" alt:decorative
694
+ img src:chart_png alt_todo:"bar chart of weekly signups — confirm axis labels"
695
+ ```
696
+
697
+ - **`alt:<text>`** — meaningful alternative text: what the image conveys, for a reader who cannot see it. Emits `alt="<text>"`. A dynamic value (`alt:"{caption}"`) is checked at render: an `alt:` that resolves to empty / whitespace raises `render.empty_alt` rather than silently emitting `alt=""` — the author said the image is meaningful, so an empty value contradicts the declaration.
698
+ - **`alt:decorative`** — the bare word `decorative`: the image is purely presentational (a rule, a texture, an icon that only repeats adjacent text). Emits `alt=""`, the WAI-ARIA-sanctioned signal for "assistive tech may skip this". A *statically* empty `alt:""` is `E-IMG-ALT`, not a synonym for this — say `decorative` when that is the intent.
699
+ - **`alt:todo`** / **`alt_todo:<text>`** — a development placeholder: real alt text is owed but not written. The build still emits a navigable `alt` (`alt_todo:`'s note, or `"TODO: describe this image"` for the bare `alt:todo`), and plain `vlcheck` / `vlbuild` **warn** (`W-IMG-ALT-TODO`). `vlcheck --release` / `vlbuild --release` promote it to `E-IMG-ALT-TODO` — the ship gate that fails a build with any unresolved placeholder.
700
+
701
+ An `img` with none of these is `E-IMG-ALT`; with more than one (e.g. `alt:` and `alt_todo:` together) it is `E-IMG-ALT-ONE`. As with §8.8 / §8.9, raw `aria-*` / `role` are not an attribute surface.
702
+
703
+ **Scope.** This slice is the `img` primitive's alternative text. Live error/status semantics are defined in §8.12.
704
+
705
+ ---
706
+
707
+ ### 8.11 Page title, headings, and landmarks
708
+
709
+ A build produces one document, rendered by a **root view** (§8, the no-argument view `VL.mount` mounts). That document — not any reusable `view` — is the page. Navigation between pages is a full browser navigation; there is no client-side router.
710
+
711
+ **Page title.** A module that builds as a page declares one, at module scope:
712
+
713
+ ```vl
714
+ page title:"Weekly Report"
715
+ ```
716
+
717
+ `title:` is an expression evaluated at module scope (a literal, or something a module-level `fn` / config produces — not view state). It becomes the document `<title>` when it is a literal, and is assigned to `document.title` at boot regardless, so a full navigation to another page picks up that page's own title. `page` with no `title:`, or a statically empty one, is `E-PAGE-TITLE`; a build that renders a page with no `page` declaration at all is refused (`E-PAGE-TITLE` at build time). A `title:` expression that resolves to empty at load time raises `render.empty_page_title`. One `page` per module (`E-PAGE-DUP`).
718
+
719
+ **Headings.** `h1`…`h6` are `std.ui` primitives that emit the real HTML heading elements. The level is **semantic and independent of visual size** — `h2 "…" size:28` is a second-level heading that happens to be large. Within one view, a heading that jumps more than one level below the previous one is `W-HEADING-SKIP`, and a second `h1` is `W-HEADING-MULTI-H1` — warnings, not errors, and scoped to a single view so a reusable component that legitimately starts at `h2` or `h3` under some page's `h1` is never flagged.
720
+
721
+ **Landmarks.** `main` and `nav` are `std.ui` primitives emitting `<main>` / `<nav>`. There is one `main` region per page (`E-MAIN-DUP` for two in a view). A `nav` takes `aria_label:` (emitted as `aria-label`); when a view has more than one `nav`, each **must** carry a distinct name (`E-NAV-NAME`) so assistive tech can tell them apart. These rules hold on the **composed** document, not just within one view: a `main` in the page-root view and in a view it renders (or a view whose `main` is reused) is `E-MAIN-COMPOSED` — every `main` emits `id="vl-main"`, so a second one is also a duplicate id and an ambiguous skip target; two `<nav>`s assistive tech cannot tell apart — both unnamed, or sharing one `aria_label:` — from different composed views are likewise `E-NAV-NAME`; and one static `id:` rendered by more than one element is `E-ID-DUP`. The composed pass follows component instantiation within a module from the page root and treats `if` / `else` branches as mutually exclusive (a landmark in both branches counts once); it does not evaluate conditions, and cross-file `use` composition is not yet followed.
722
+
723
+ **Skip link.** When the page renders a `main` landmark, the compiler tags it with a stable id and `tabindex="-1"` and injects a **"Skip to main content"** link as the first thing in the tab order, off-screen until focused and then visible (styled `.vl-skip`). Activating it moves focus into `main`. The author writes nothing.
724
+
725
+ As with §8.8–§8.10, raw `role` / `aria-*` are not an attribute surface.
726
+
727
+ **Scope.** This slice is the page title, heading elements, the `main` / `nav` landmarks, and the skip link. These structural checks do not prove a page is accessible or replace screen-reader testing. Live error/status semantics are defined in §8.12.
728
+
729
+ ---
730
+
731
+ ### 8.12 Live status and validation feedback
732
+
733
+ `status <message>` is a text-only `std.ui` primitive. It takes exactly one
734
+ string-or-nil expression, no attributes and no children (`E-STATUS-SHAPE`
735
+ with a repair example otherwise). This bounded form does not expose raw ARIA,
736
+ priority switches or focus controls.
737
+
738
+ ```vl
739
+ view Feedback()
740
+ state message = ""
741
+ status message
742
+ button "Save"
743
+ on tap
744
+ message = "Saved."
745
+ ```
746
+
747
+ Keep the status element mounted, using nil or an empty string while idle,
748
+ rather than conditionally inserting a populated region. The compiler emits a
749
+ span with `role="status"`, `aria-live="polite"`, and `aria-atomic="true"`.
750
+ The region remains exposed when empty. A non-string/non-nil runtime value
751
+ raises `render.invalid_status`. Initial populated content is visible but is
752
+ not promised to be announced; the contract concerns subsequent updates.
753
+
754
+ An input declaring `error:` automatically gets the same polite atomic
755
+ live-region behavior in its existing error span, outside the label. The
756
+ region is present even when empty, without `hidden` or `display:none`.
757
+ Non-empty errors still participate in `aria-describedby` and set
758
+ `aria-invalid`; clearing removes those error associations and the invalid
759
+ state, but retains the empty region.
760
+
761
+ For a retained element, the runtime updates the existing text node only when
762
+ its value changes. Unrelated renders and repeated assignment of the same
763
+ message do not rewrite it. Clearing and subsequently setting a message allows
764
+ a new update. There is no automatic reannouncement of an unchanged error on
765
+ repeated submission. Updates do not move focus. Regions in dialogs live inside
766
+ the dialog portal, not in an inert background or a global announcement queue.
767
+
768
+ Authors choose when to expose validation errors (for example after submission
769
+ or blur) and supply useful context in messages. The compiler cannot infer that
770
+ ordinary dynamic `text` is a status message. Structural removal/reinsertion
771
+ does not preserve region identity, and no speech guarantee is made for it.
772
+
773
+ **Evidence boundary.** Browser checks establish live-region properties,
774
+ in-place text mutation, unchanged-message suppression, clearing and portal
775
+ behavior. They do not establish audible output, announcement order for multiple
776
+ simultaneous regions, or screen-reader interoperability. Manual AT testing
777
+ remains required. This follows the pre-existing-region approach in
778
+ [W3C ARIA22](https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22);
779
+ [ARIA19](https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA19) discusses error
780
+ notification. Polite feedback is the language default; urgent alerts are
781
+ outside this slice.
782
+
783
+ ---
784
+
785
+ ### 8.13 Project preferences and automation hooks
786
+
787
+ The checker and builder read versioned `volaro.json`, independently of agent
788
+ chat history. Version 1 is a JSON object with required integer
789
+ `"schema_version": 1` and optional `"testing": {"emit_ids": true}`.
790
+ The default is true. Unknown keys, duplicate JSON keys, wrong types and
791
+ unsupported versions are errors (E-CONFIG-JSON / E-CONFIG-OPTION /
792
+ E-CONFIG-VERSION); unreadable files are E-CONFIG-READ. A config found under the
793
+ former name `volara.json` is E-CONFIG-LEGACY, never a silent skip and never a
794
+ silent fallback to defaults. No accessibility opt-out or not-yet-supported
795
+ wizard setting is accepted.
796
+
797
+ Discovery starts at the entry source directory, selects the nearest
798
+ `volaro.json`, and stops at a Git worktree boundary (a .git file or directory)
799
+ or filesystem root. An explicit `--config PATH` selects exactly that file
800
+ relative to the command's working directory. A bad nearest file is an error,
801
+ not a fallback. A `volara.json` at or above the entry directory — or named by
802
+ `--config` — raises E-CONFIG-LEGACY naming the `volara.json` → `volaro.json`
803
+ rename, and if both names sit in one directory that is also E-CONFIG-LEGACY.
804
+ Builds never write configuration. Included modules use the entry
805
+ settings. No secrets or environment credentials belong in this file.
806
+
807
+ `test_id:"save"` emits `data-testid="save"`. IDs are static, non-interpolated
808
+ lowercase slugs matching `[a-z][a-z0-9-]{0,79}`. Empty/invalid/duplicate
809
+ attributes and unsupported targets are E-TEST-ID. Supported targets are
810
+ col/row/stack/grid/card/text/img/button/input/link/spacer, h1–h6, main/nav,
811
+ and status. Input hooks attach to the input, not its field wrapper. Hook names
812
+ do not replace HTML IDs, accessible names, or reconciliation keys.
813
+
814
+ `test_scope:<string expression>` creates a namespace for its element and
815
+ descendants, including local component calls and dialog content. Joined IDs
816
+ use slash separators: `editor/name`. Each scope segment obeys the same slug
817
+ rule. Static invalid scopes are E-TEST-ID-SCOPE; dynamic invalid scopes raise
818
+ `render.invalid_test_scope` without echoing the value. Component calls,
819
+ dialog and tabs may carry scopes, but not direct test_id hooks. Individual
820
+ tab headers do not support either attribute in this pass; unsupported targets
821
+ fail rather than being silently ignored.
822
+
823
+ Repeated rows author an explicit stable public scope on their row or component.
824
+ The compiler NEVER derives scope from the list key, index, label or source
825
+ location. Authors must not use emails, credentials, tokens or private record
826
+ identifiers. Validation cannot establish that an otherwise valid value is
827
+ non-sensitive or stable; review remains required.
828
+
829
+ Provable simultaneous literal collisions are E-TEST-ID-DUP. Static analysis
830
+ expands local views, adds sibling counts and takes maximum per-ID counts over
831
+ if/else alternatives; repeated literal scopes collide. Dynamic scopes and
832
+ relationships not statically known are checked during rendering. The next
833
+ render uses a fresh registry spanning page and prepared portals; a collision
834
+ raises `render.duplicate_test_id` before committing the new page or portals,
835
+ preserving the preceding valid DOM. Scope identity survives keyed reordering.
836
+ Closed/removed content may reuse its IDs on a later render.
837
+
838
+ With emission disabled, no hook attributes, scope-expression evaluation or
839
+ hook registry calls are emitted in application code. Static validation still
840
+ runs. Accessibility, handlers and reconciliation behaviour are unchanged;
841
+ dynamic-hook errors are emission-only. Existing single-module view compilation
842
+ boundaries remain: this does not add cross-file view loading or a router.
843
+
844
+ ## 9. Outbound API integration
845
+
846
+ An `api` block declares a client for a service someone else runs.
847
+
848
+ ```vl
849
+ api blog
850
+ base "https://api.example.com/v2"
851
+ auth bearer env.BLOG_TOKEN
852
+ timeout 10s
853
+ retry 3 backoff:exp on:[429, 502, 503, 504]
854
+ rate 100/min
855
+
856
+ headers
857
+ accept "application/json"
858
+
859
+ get list_posts(topic str, limit int = 20) list<Post>
860
+ path "/posts"
861
+ query topic, limit
862
+
863
+ get get_post(id uuid) Post!
864
+ path "/posts/{id}"
865
+
866
+ post create_post(body NewPost) Post!
867
+ path "/posts"
868
+
869
+ delete remove_post(id uuid) nil!
870
+ path "/posts/{id}"
871
+ ```
872
+
873
+ Each endpoint becomes a function at `blog.list_posts(...)`, namespaced under the block name. Path parameters interpolate from the parameter list by name and are URL-escaped. `query` names which parameters go in the query string.
874
+
875
+ Where an unrouted parameter goes depends on whether the method carries a request body. For `get`, `delete`, and `head` a parameter that is in neither the path nor `query` has nowhere to go: it is a compile error, so a forgotten parameter never silently disappears. For `post`, `put`, and `patch` the unrouted parameters form the request body — a parameter named `body` is sent as the body verbatim, and any other unrouted parameters are collected into a JSON object keyed by parameter name.
876
+
877
+ Response bodies decode into the declared type. A field present in the type and missing from the response is a runtime `Err` with code `decode.missing_field` and the JSON pointer in `fields`. A field present in the response and absent from the type is ignored.
878
+
879
+ `timeout` defaults to `30s` when the block omits it; `timeout none` disables it, and is the only way to make a call that can hang forever. `retry` applies only to idempotent methods unless `retry ... idempotent:true` is stated on a `post`. `rate` throttles client-side with a token bucket shared across the process.
880
+
881
+ ### 9.1 Generating a client from a schema
882
+
883
+ ```vl
884
+ api stripe from openapi("./schemas/stripe.json")
885
+ auth bearer env.STRIPE_KEY
886
+ timeout 30s
887
+ ```
888
+
889
+ The compiler reads the schema at build time and generates typed endpoints. Overrides are written in the block body and take precedence over the schema.
890
+
891
+ ---
892
+
893
+ ## 10. Inbound services
894
+
895
+ A `service` block declares HTTP routes the application serves.
896
+
897
+ ```vl
898
+ service api
899
+ prefix "/api/v1"
900
+
901
+ get /posts(topic str?, limit int = 20) list<Post>!
902
+ db.posts.where(topic: topic).limit(limit).all()?
903
+
904
+ get /posts/:id Post!
905
+ db.posts.get(id)?
906
+
907
+ post /posts(body NewPost) Post!
908
+ auth role:author
909
+ let post = db.posts.insert(body, author_id: session.user.id)?
910
+ events.emit("post.created", post.id)
911
+ post
912
+
913
+ patch /posts/:id(body PostPatch) Post!
914
+ auth owner:db.posts.get(id)?.author_id
915
+ db.posts.update(id, body)?
916
+
917
+ delete /posts/:id nil!
918
+ auth role:admin
919
+ db.posts.delete(id)?
920
+ ```
921
+
922
+ Path parameters are typed from the route pattern and available as bindings. `:id` in a route whose handler expects `uuid` parses and validates before the body runs; a malformed value returns 400 without entering user code.
923
+
924
+ Return values serialize to JSON. A `T!` handler maps error codes to status codes through a table declared once:
925
+
926
+ ```vl
927
+ service api
928
+ errors
929
+ not_found -> 404
930
+ forbidden -> 403
931
+ conflict -> 409
932
+ _ -> 500
933
+ ```
934
+
935
+ Unmapped error codes return 500 and log the full error with cause chain. The response body for a 500 never includes the internal message.
936
+
937
+ **Public routes.** A route with no `auth` line is public, and the compiler warns when more than half of a service's routes are public, on the assumption that the guards were forgotten. A service that is *meant* to be public says so once and the warning does not apply:
938
+
939
+ ```vl
940
+ service content public # every route is public unless it says otherwise
941
+ get /posts list<Post>!
942
+ get /posts/:id Post!
943
+
944
+ post /posts(body NewPost) Post!
945
+ auth role:author # a guard still overrides, per route
946
+ ```
947
+
948
+ A single deliberately public route inside an otherwise guarded service uses `auth none`, which is distinct from omitting the line: it records the decision, and it silences the warning for that route only.
949
+
950
+ ```vl
951
+ get /health str
952
+ auth none
953
+ ```
954
+
955
+ `auth none` on a write route (`post`/`put`/`patch`/`delete`) is still a warning, because an unauthenticated write is a different claim from an unauthenticated read and deserves a second look.
956
+
957
+ A `service` also generates a typed client under its own name, so view code calls `api.posts(limit: 20)` and gets a `list<Post>` back with no URL string, no fetch wrapper, and no hand-written response type. Changing a route signature breaks its callers at compile time, which is the property a hand-rolled client never has.
958
+
959
+ **Generated method names.** The name comes from the route's path *and* its HTTP method, not its handler. Three shapes, chosen by where the path parameters sit:
960
+
961
+ 1. **Collection** — no path parameters. `get` keeps the last segment as written (`get /posts` → `api.posts(...)`); `post` is `create_<singular>` (`post /posts` → `api.create_post(...)`); `put`/`patch` are `update_<singular>`; `delete` is `delete_<singular>`.
962
+ 2. **Item** — the path ends in a parameter. `get` is the singularised last non-parameter segment (`get /posts/:id` → `api.post(id: ...)`); `post` is `create_<singular>`; `put`/`patch` are `update_<singular>`; `delete` is `delete_<singular>` (`delete /posts/:id` → `api.delete_post(id: ...)`).
963
+ 3. **Action** — the path ends in a non-parameter segment but has a parameter earlier. The name is that segment verbatim, for any method (`post /posts/:id/publish` → `api.publish(id: ...)`).
964
+
965
+ Singularisation strips a trailing `es` after `s`, `x`, `z`, `ch`, `sh`, then a trailing `s`; a name that neither rule changes gets the suffix `_item` (`/data/:id` → `api.data_item`).
966
+
967
+ Path parameters become required named arguments, in path order, before any query or body arguments.
968
+
969
+ Two routes generating the same name is a compile error naming both — a single namespace, regardless of method. Disambiguate with `as`:
970
+
971
+ ```vl
972
+ get /users/:id/avatar File! # action → api.avatar(id:)
973
+ get /teams/:id/avatar File! as team_avatar # would also be `avatar` — clashes
974
+ ```
975
+
976
+ Method names are part of the service's public surface: renaming a path renames the client call and breaks callers at compile time, which is the intent.
977
+
978
+ ### 10.1 Middleware
979
+
980
+ ```vl
981
+ service api
982
+ before
983
+ log.request()
984
+ cors(origins: ["https://app.example.com"])
985
+
986
+ after
987
+ log.response()
988
+ ```
989
+
990
+ `before` runs in declaration order and short-circuits on the first error. There is no `next()` call to forget.
991
+
992
+ ---
993
+
994
+ ## 11. Authentication and authorization
995
+
996
+ The `auth` block is declared once per project.
997
+
998
+ ```vl
999
+ auth
1000
+ user User # the record sessions attach to
1001
+ identity email # the field used as login identity
1002
+
1003
+ provider password
1004
+ min_len 12
1005
+ breached_check true
1006
+ verify_email true
1007
+
1008
+ provider oauth google
1009
+ client_id env.GOOGLE_CLIENT_ID
1010
+ secret env.GOOGLE_SECRET
1011
+ scopes ["openid", "email", "profile"]
1012
+
1013
+ provider totp
1014
+ issuer "Example"
1015
+ required_for [admin]
1016
+
1017
+ session
1018
+ store cookie
1019
+ ttl 30d
1020
+ idle_timeout 12h
1021
+ rotate_on_login true
1022
+ rotate_on_privilege_change true
1023
+
1024
+ roles admin, author, member
1025
+ default_role member
1026
+ ```
1027
+
1028
+ ### 11.1 What the compiler guarantees
1029
+
1030
+ These properties hold with no way to disable them from Volaro source:
1031
+
1032
+ - Passwords hash with Argon2id at parameters the compiler pins and updates by release. There is no field type that stores a plaintext password and no library call that accepts one.
1033
+ - Session cookies are `HttpOnly`, `Secure`, and `SameSite=Lax` (`Strict` when no OAuth provider is declared). The names and flags are not user-settable.
1034
+ - State-changing routes (`post`, `patch`, `put`, `delete`) require a valid CSRF token when the session store is `cookie`. The token is issued and checked without appearing in application code.
1035
+ - Session identifiers are 256 bits from the platform CSPRNG, stored hashed, and rotated on login and on any role change.
1036
+ - Login, password reset, and TOTP verification routes are rate-limited by identity and by source address.
1037
+ - Timing-safe comparison is the only comparison available for secrets, tokens, and hashes.
1038
+ - Password reset tokens are single-use, expire in 30 minutes, and never appear in logs or error messages.
1039
+ - Email verification tokens carry the same discipline with a longer life: single-use, expiring in 24 hours, stored hashed, and never appearing in logs, error messages, or response bodies. Redeeming one marks the account verified through the auth API. This does not make the model's `verified` field compiler-owned or prohibit ordinary model updates to it.
1040
+
1041
+ An audit of generated authentication code usually finds two or three of these missing. Moving them below the source line means a generator cannot omit them.
1042
+
1043
+ These guarantees cover *who may call a route*. They do **not** yet cover *what a caller may see*: nothing stops `db.posts.get(id)` from returning a row outside a record's intended public scope (e.g. an unpublished `Post`). Data-scope authorization is open question §20.7.
1044
+
1045
+ ### 11.2 Guards
1046
+
1047
+ ```vl
1048
+ auth required # any signed-in user
1049
+ auth role:admin # role check
1050
+ auth role:[admin, author] # any listed role
1051
+ auth owner:post.author_id # session user owns the resource
1052
+ auth optional # populate session if present, allow if not
1053
+ ```
1054
+
1055
+ A route with no `auth` line is public, and the compiler warns once per project if more than half the routes are public, which catches the case where a generator forgot the guards entirely.
1056
+
1057
+ Inside a guarded handler, `session.user` is the non-optional user record. Inside an `auth optional` handler it is `User?`. The type difference is what stops a null dereference.
1058
+
1059
+ Guards do not currently compose. In particular, there is no declarative form
1060
+ for the common policy “an administrator **or** this resource's owner.” Writing
1061
+ that comparison in the handler is possible, but makes authorization omissible
1062
+ application code and therefore weakens goal 4 in §1.1. This is an explicit v0.3
1063
+ language gap, not an idiomatic workaround; see open question §20.8.
1064
+
1065
+ ### 11.3 Auth in views
1066
+
1067
+ ```vl
1068
+ view Nav()
1069
+ row gap:16
1070
+ link "Home" to:"/"
1071
+ if session.user
1072
+ link "Write" to:"/new"
1073
+ if session.user.role == admin
1074
+ link "Admin" to:"/admin"
1075
+ button "Sign out" on tap: auth.sign_out()
1076
+ else
1077
+ link "Sign in" to:"/login"
1078
+ ```
1079
+
1080
+ The client-side surface is eight calls. Each returns a fallible value with a documented error union, so a form handles failures by name rather than by string matching an error message.
1081
+
1082
+ | Call | Returns | Error union |
1083
+ |---|---|---|
1084
+ | `auth.sign_up(email:, password:)` | `Session!` | `email_taken`, `weak_password`, `breached_password`, `rate_limited(retry_after dur)` |
1085
+ | `auth.sign_in(email:, password:)` | `Session!` | `invalid_credentials`, `unverified_email`, `totp_required`, `rate_limited(retry_after dur)` |
1086
+ | `auth.sign_out()` | `nil!` | — |
1087
+ | `auth.start_oauth(provider)` | `nil!` | `provider_error`, `rate_limited(retry_after dur)` |
1088
+ | `auth.reset_password(email:)` | `nil!` | `rate_limited(retry_after dur)` |
1089
+ | `auth.confirm_reset(token:, password:)` | `Session!` | `invalid_token`, `expired_token`, `weak_password`, `breached_password` |
1090
+ | `auth.confirm_verify(token:)` | `Session!` | `invalid_token`, `expired_token` |
1091
+ | `auth.verify_totp(code:)` | `Session!` | `invalid_code`, `no_totp_enrolled`, `rate_limited(retry_after dur)` |
1092
+
1093
+ `sign_up` returns a session only when `verify_email false`; with `verify_email true` (the default) it returns after sending the verification mail and the caller routes to a "check your email" state. `confirm_verify` is the other half of that flow, and without it `verify_email true` has no exit: it redeems the token from that mail, marks the account verified, and establishes a session. **Because it returns `Session!`, a successful confirmation also signs the user in** — the newcomer follows the link and lands in the application authenticated, with no sign-in form in between. A caller that presents one anyway is asking an already-authenticated user to prove themselves twice. `reset_password` never reveals whether the address is registered: it succeeds uniformly for known and unknown addresses. `confirm_reset`, `confirm_verify` and `verify_totp` establish a fresh session and rotate the session id, so no separate sign-in call follows them.
1094
+
1095
+ `start_oauth` takes a bare provider name matching a `provider oauth <name>` declaration in the `auth` block (`auth.start_oauth(google)`).
1096
+
1097
+ ---
1098
+
1099
+ ## 12. Concurrency
1100
+
1101
+ Every IO operation is asynchronous. There is no `async` keyword and no `await`, because the color of a function is derivable from its body and carrying it in the signature adds tokens without adding information. The compiler computes it and the type system enforces the same rules a colored system would.
1102
+
1103
+ Sequential code is sequential:
1104
+
1105
+ ```vl
1106
+ let user = db.users.get(id)?
1107
+ let posts = db.posts.by_author(user.id)?
1108
+ ```
1109
+
1110
+ Parallel code says so:
1111
+
1112
+ ```vl
1113
+ par
1114
+ let user = db.users.get(id)?
1115
+ let posts = blog.list_posts(topic: "rust")?
1116
+ let quota = billing.quota(id)?
1117
+ ```
1118
+
1119
+ Bindings inside `par` start together and are available after the block. A failure in any branch cancels the others and propagates.
1120
+
1121
+ Background work is explicit and does not block a response:
1122
+
1123
+ ```vl
1124
+ spawn
1125
+ mailer.send_welcome(user.email)
1126
+ ```
1127
+
1128
+ A spawned task that fails logs and does not affect the caller. Fire-and-forget is the only spawn form; anything needing a result uses `par`.
1129
+
1130
+ `spawn` is legal in a view body, including inside an event handler. Unlike `load` (§8.4), a spawned task is not cancelled when the view unmounts: it runs to completion, and a failure logs without affecting the view.
1131
+
1132
+ ---
1133
+
1134
+ ## 13. Standard library surface
1135
+
1136
+ The library is small on purpose. A large surface is a large thing to remember and a large thing to hallucinate.
1137
+
1138
+ | Module | Contents |
1139
+ |---|---|
1140
+ | `std.ui` | layout and input components, auto-imported in views |
1141
+ | `std.http` | request, response, status codes, header helpers |
1142
+ | `std.db` | query builder, migrations, transactions |
1143
+ | `std.time` | `now`, formatting, parsing, arithmetic on `time` and `dur` |
1144
+ | `std.str` | split, join, trim, case, replace, regex |
1145
+ | `std.list` | map, filter, fold, sort_by, group_by, take, chunk, concat, append |
1146
+ | `std.map` | keys, values, merge, get_or |
1147
+ | `std.json` | encode, decode, pointer access |
1148
+ | `std.crypto` | random bytes, hashing, timing-safe compare |
1149
+ | `std.log` | structured logging at five levels |
1150
+ | `std.test` | assertions, fixtures, HTTP and view test harnesses |
1151
+ | `std.sync` | reference cells, channels, mutex |
1152
+ | `std.ffi` | Rust boundary |
1153
+
1154
+ ### 13.1 Database access
1155
+
1156
+ ```vl
1157
+ model Post
1158
+ id uuid pk
1159
+ title str
1160
+ body text
1161
+ author_id uuid ref:User.id
1162
+ published bool = false
1163
+ created_at time = now()
1164
+
1165
+ index (author_id, created_at desc)
1166
+ ```
1167
+
1168
+ `model` is a record with persistence. Migrations generate from the diff between committed models and the database schema, and `vl migrate` applies them with a printed plan and a required confirmation for destructive steps.
1169
+
1170
+ Queries are typed:
1171
+
1172
+ ```vl
1173
+ db.posts
1174
+ .where(published: true, author_id: user.id)
1175
+ .order(created_at: desc)
1176
+ .limit(20)
1177
+ .all()?
1178
+ ```
1179
+
1180
+ A bare value in `where` is exact equality, as above. A **comparison predicate**
1181
+ wraps the value instead of matching it, for everything equality can't express —
1182
+ `where` still ANDs one predicate per named column:
1183
+
1184
+ ```vl
1185
+ db.posts
1186
+ .where(published: true, created_at: lt(cursor))
1187
+ .order(created_at: desc)
1188
+ .limit(20)
1189
+ .all()?
1190
+ ```
1191
+
1192
+ | predicate | SQL |
1193
+ |---|---|
1194
+ | `lt(x)` / `lte(x)` | `< x` / `<= x` |
1195
+ | `gt(x)` / `gte(x)` | `> x` / `>= x` |
1196
+ | `ne(x)` | `!= x` |
1197
+ | `between(lo, hi)` | `BETWEEN lo AND hi` |
1198
+ | `any_of(xs)` | `IN (xs…)` — `xs` a `list<T>`; an empty list matches no rows |
1199
+
1200
+ Each predicate lowers to exactly one parameterized SQL fragment — there is no
1201
+ way to interpolate a raw comparison, and the emitter rejects a `where` value
1202
+ it does not recognize (a call to any name outside this table) rather than
1203
+ silently binding it as an opaque parameter. `where` composes **only** by AND
1204
+ across its named arguments; there is no `or` or nested-boolean form. A query
1205
+ needing one is a `db.raw` case, not a reason to grow this table speculatively
1206
+ — add the next predicate when a real feature needs it, the same discipline
1207
+ `std.ui` follows for accessible primitives (§8.8).
1208
+
1209
+ Raw SQL is available through `db.raw("...", args)` and returns `list<json>`, forcing an explicit decode. Interpolation into `db.raw` is a compile error; parameters are positional only.
1210
+
1211
+
1212
+ ---
1213
+
1214
+ ## 14. Token economy
1215
+
1216
+ The claim to test is that a feature costs fewer tokens in Volaro. Here is one feature, written both ways: a sign-in form that posts credentials, handles four failure modes, and redirects on success.
1217
+
1218
+ ### 14.1 Volaro
1219
+
1220
+ ```vl
1221
+ view SignIn()
1222
+ state email = ""
1223
+ state password = ""
1224
+ state error str? = nil
1225
+
1226
+ col gap:12 pad:24 max_w:360
1227
+ text "Sign in" size:24 weight:600
1228
+ input bind:email type:email placeholder:"Email"
1229
+ input bind:password type:password placeholder:"Password"
1230
+ if error
1231
+ text error color:danger size:13
1232
+ button "Sign in" variant:primary
1233
+ on tap
1234
+ error = nil
1235
+ try auth.sign_in(email: email, password: password)
1236
+ catch invalid_credentials
1237
+ error = "Wrong email or password."
1238
+ catch unverified_email
1239
+ route.to("/verify")
1240
+ catch rate_limited(retry_after)
1241
+ error = "Too many attempts. Try again in {retry_after}."
1242
+ catch e
1243
+ error = "Something went wrong."
1244
+ route.to("/")
1245
+ ```
1246
+
1247
+ 24 lines, 768 characters.
1248
+
1249
+ ### 14.2 The same feature on a React, Express, and Passport stack
1250
+
1251
+ The equivalent needs a client component with four `useState` hooks and a submit handler, a `POST /login` route, a Passport local strategy, a session middleware configuration, a bcrypt comparison, and a rate limiter registration. Across those files the working version runs 180 to 220 lines and 5,500 to 6,500 characters, before the imports.
1252
+
1253
+ ### 14.3 Ratio
1254
+
1255
+ **Measured, 2026-09-03** (`o200k_base`, 7 hand-written features, both arms, against Next.js 15 + Auth.js v5 + Prisma + `@node-rs/argon2` — a stack that already collapses much of the boilerplate):
1256
+
1257
+ | | per feature |
1258
+ |---|---|
1259
+ | Volaro | 297 tokens |
1260
+ | baseline | 1 192 tokens |
1261
+ | **ratio** | **4.01×** |
1262
+
1263
+ That is below the 5:1–8:1 the earlier draft projected, and the band structure is not what was predicted either: the widest feature is TOTP (7.0×) and the narrowest so far is a list-and-detail view (2.2×), so "views are the wide end" does not hold against modern server components. Authentication is where the claim is strongest.
1264
+
1265
+ The number that matters for an agent is context: a feature costs 895 fewer tokens to hold in a window, so a 40-feature codebase is 3.5× cheaper to work in, and the ~1 700-token generation cheatsheet pays for itself after two files.
1266
+
1267
+ Seven of the planned forty features are written. The measurement is provisional in n, not in method.
1268
+
1269
+ ### 14.4 Reading cost
1270
+
1271
+ Generation cost is only half the number. A model editing an existing project spends most of its budget reading. `vl map` emits the project surface:
1272
+
1273
+ ```
1274
+ view SignIn()
1275
+ view Feed(topic str)
1276
+ view UserCard(user User, compact bool = false)
1277
+ model Post{id, title, body, author_id -> User.id, published, created_at}
1278
+ model User{id, email, name?, role, created_at}
1279
+ service api GET /api/v1/posts(topic str?, limit int) -> list<Post>! public
1280
+ service api POST /api/v1/posts(body NewPost) -> Post! auth:role[author]
1281
+ api blog GET list_posts(topic str, limit int) -> list<Post>
1282
+ auth providers[password, oauth:google, totp] roles[admin, author, member]
1283
+ ```
1284
+
1285
+ One line per surface element. A 6,000-line project produces a map of roughly 300 lines, so the whole shape of the application fits in a few thousand tokens and a model reads only the files it intends to change.
1286
+
1287
+ ---
1288
+
1289
+ ## 15. Worked example
1290
+
1291
+ A complete application: a blog with public reading, authenticated writing, and an outbound call to a moderation API.
1292
+
1293
+ ```vl
1294
+ # src/models.vl
1295
+ use std.time { now }
1296
+
1297
+ type Role = admin | author | member
1298
+
1299
+ model User
1300
+ id uuid pk
1301
+ email str unique
1302
+ name str?
1303
+ role Role = member
1304
+ created_at time = now()
1305
+
1306
+ model Post
1307
+ id uuid pk
1308
+ title str
1309
+ body text
1310
+ author_id uuid ref:User.id
1311
+ published bool = false
1312
+ created_at time = now()
1313
+
1314
+ index (published, created_at desc)
1315
+
1316
+ type NewPost
1317
+ title str
1318
+ body text
1319
+ ```
1320
+
1321
+ ```vl
1322
+ # src/auth.vl
1323
+ use ./models { User }
1324
+
1325
+ auth
1326
+ user User
1327
+ identity email
1328
+
1329
+ provider password
1330
+ min_len 12
1331
+ breached_check true
1332
+ verify_email true
1333
+
1334
+ session
1335
+ store cookie
1336
+ ttl 14d
1337
+ rotate_on_login true
1338
+
1339
+ roles admin, author, member
1340
+ default_role member
1341
+ ```
1342
+
1343
+ ```vl
1344
+ # src/api.vl
1345
+ use ./models { Post, NewPost }
1346
+
1347
+ api moderation
1348
+ base "https://mod.example.com/v1"
1349
+ auth bearer env.MOD_TOKEN
1350
+ timeout 5s
1351
+ retry 2 backoff:exp on:[429, 503]
1352
+
1353
+ post check(text str) Verdict!
1354
+ path "/check"
1355
+
1356
+ type Verdict
1357
+ allowed bool
1358
+ reason str?
1359
+
1360
+ service api
1361
+ prefix "/api"
1362
+
1363
+ errors
1364
+ not_found -> 404
1365
+ forbidden -> 403
1366
+ rejected -> 422
1367
+ _ -> 500
1368
+
1369
+ get /posts(limit int = 20) list<Post>!
1370
+ db.posts.where(published: true).order(created_at: desc).limit(limit).all()?
1371
+
1372
+ get /posts/:id Post!
1373
+ db.posts.get(id)?
1374
+
1375
+ post /posts(body NewPost) Post!
1376
+ auth role:[author, admin]
1377
+ let verdict = moderation.check(text: body.body)?
1378
+ if not verdict.allowed
1379
+ return err(rejected(verdict.reason ?? "content rejected"))
1380
+ db.posts.insert(body, author_id: session.user.id)?
1381
+
1382
+ delete /posts/:id nil!
1383
+ auth owner:db.posts.get(id)?.author_id
1384
+ db.posts.delete(id)?
1385
+ ```
1386
+
1387
+ ```vl
1388
+ # src/views/Feed.vl
1389
+ use ../models { Post }
1390
+
1391
+ view Feed()
1392
+ load posts = api.posts(limit: 20)
1393
+ pending
1394
+ Spinner()
1395
+ error e
1396
+ col gap:8 pad:24
1397
+ text "Could not load posts." color:danger
1398
+ button "Retry" on tap: posts.reload()
1399
+
1400
+ col gap:16 pad:24 max_w:720
1401
+ row justify:between align:center
1402
+ text "Latest" size:28 weight:700
1403
+ if session.user and session.user.role != member
1404
+ link "Write" to:"/new"
1405
+
1406
+ if posts.len() == 0
1407
+ text "Nothing published yet." color:muted
1408
+ list posts as p key:p.id
1409
+ col gap:4
1410
+ link p.title to:"/posts/{p.id}" size:18 weight:600
1411
+ text p.created_at.format("MMM d, yyyy") size:13 color:muted
1412
+ ```
1413
+
1414
+ Four files, 109 lines, and the result is a deployable application with hashed passwords, rotated sessions, CSRF protection on the write routes, a retrying moderation client, keyed list rendering, and a loading state.
1415
+
1416
+ ---
1417
+
1418
+ ## 16. Grammar
1419
+
1420
+ Core grammar in EBNF. `NL`, `INDENT`, and `DEDENT` are lexer-synthesized tokens. `block(X)` abbreviates `NL INDENT X+ DEDENT`.
1421
+
1422
+ ```ebnf
1423
+ program = { item } ;
1424
+ item = use | type_decl | model_decl | fn_decl | view_decl
1425
+ | api_decl | service_decl | auth_decl | theme_decl ;
1426
+
1427
+ use = "use" path [ "{" ident { "," ident } "}" ] NL ;
1428
+ path = ident { "." ident } | rel_path ;
1429
+
1430
+ type_decl = "type" TypeName ( record_body | "=" union_body | "=" type ) ;
1431
+ record_body = block( field ) ;
1432
+ field = ident type [ "=" expr ] NL ;
1433
+ union_body = variant { "|" variant } NL
1434
+ | block( variant ) ;
1435
+ variant = ident [ "(" param { "," param } ")" ] ;
1436
+
1437
+ model_decl = "model" TypeName block( field | index_decl ) ;
1438
+ index_decl = "index" "(" ident [ "desc" ] { "," ident [ "desc" ] } ")" NL ;
1439
+
1440
+ fn_decl = [ doc ] "fn" ident "(" [ params ] ")" [ type ] block( stmt ) ;
1441
+ params = param { "," param } ;
1442
+ param = ident type [ "=" expr ] ;
1443
+
1444
+ type = TypeName [ "<" type { "," type } ">" ]
1445
+ | prim
1446
+ | type "?"
1447
+ | type "!" [ type ]
1448
+ | "fn" "(" [ type { "," type } ] ")" type ;
1449
+
1450
+ stmt = let | assign | if_stmt | match_stmt | for | while
1451
+ | "break" NL | "skip" NL | "return" [ expr ] NL
1452
+ | par | spawn | try_stmt | expr NL ;
1453
+
1454
+ let = "let" [ "mut" ] ident [ type ] "=" expr NL ;
1455
+ assign = lvalue ( "=" | "+=" | "-=" | "*=" | "/=" ) expr NL ;
1456
+
1457
+ if_stmt = "if" expr block( stmt ) { "else" "if" expr block( stmt ) }
1458
+ [ "else" block( stmt ) ] ;
1459
+ match_stmt = "match" expr block( arm ) ;
1460
+ arm = pattern [ "if" expr ] "->" ( expr NL | block( stmt ) ) ;
1461
+
1462
+ for = "for" ident [ "," ident ] "in" expr block( stmt ) ;
1463
+ while = "while" expr block( stmt ) ;
1464
+ par = "par" block( let ) ;
1465
+ spawn = "spawn" block( stmt ) ;
1466
+ try_stmt = "try" expr NL { "catch" pattern block( stmt ) } ;
1467
+
1468
+ view_decl = "view" TypeName "(" [ params ] ")" block( view_stmt ) ;
1469
+ view_stmt = "state" ident [ type ] "=" expr NL
1470
+ | "derive" ident "=" expr NL
1471
+ | load_stmt
1472
+ | "on" lifecycle block( stmt )
1473
+ | "list" expr "as" ident "key" ":" expr block( view_stmt )
1474
+ | element
1475
+ | if_stmt | match_stmt | stmt ;
1476
+ load_stmt = "load" ident "=" expr
1477
+ block( "pending" block( view_stmt )
1478
+ | "error" ident block( view_stmt ) ) ;
1479
+ lifecycle = "mount" | "unmount" | "change" ident { "," ident } ;
1480
+ element = TypeName { attr } [ block( view_stmt ) ] NL ;
1481
+ attr = ident ":" expr ;
1482
+
1483
+ api_decl = "api" ident [ "from" expr ] block( api_setting | endpoint ) ;
1484
+ endpoint = method ident "(" [ params ] ")" type block( ep_setting ) ;
1485
+ ep_setting = "path" str NL | "query" ident { "," ident } NL | "body" ident NL ;
1486
+
1487
+ service_decl= "service" ident block( svc_setting | route ) ;
1488
+ route = method route_path [ "(" params ")" ] type block( route_stmt ) ;
1489
+ route_stmt = "auth" guard NL | stmt ;
1490
+ guard = "required" | "optional" | "role" ":" role_list
1491
+ | "owner" ":" expr ;
1492
+
1493
+ auth_decl = "auth" block( auth_setting ) ;
1494
+ method = "get" | "post" | "put" | "patch" | "delete" ;
1495
+ ```
1496
+
1497
+ The full grammar including operator precedence and the theme block lives in `grammar/volaro.ebnf` in the reference repository.
1498
+
1499
+ ---
1500
+
1501
+ ## 17. Reference implementation in Rust
1502
+
1503
+ Rust is the implementation language for three reasons: the compiler ships as a single static binary with no runtime dependency, the server backend compiles to Rust and reuses the same type representations, and the incremental query and CST libraries the project needs already exist there and are proven in `rust-analyzer`.
1504
+
1505
+ ### 17.1 Workspace layout
1506
+
1507
+ ```
1508
+ volaro/
1509
+ crates/
1510
+ vl-lexer # source -> tokens, indentation handling
1511
+ vl-syntax # CST (rowan), parser, typed AST view
1512
+ vl-hir # desugared, name-resolved tree
1513
+ vl-types # type checker, inference, exhaustiveness
1514
+ vl-vir # typed IR, the input to every backend
1515
+ vl-interp # tree-walking interpreter over VIR
1516
+ vl-emit-ts # TypeScript emitter for views
1517
+ vl-emit-rs # Rust emitter for services
1518
+ vl-auth # auth code generation, pinned crypto parameters
1519
+ vl-db # model diffing, migration generation
1520
+ vl-diag # diagnostic types, rendering, JSON output
1521
+ vl-fmt # canonical formatter
1522
+ vl-lsp # language server
1523
+ vl-cli # the `vl` binary
1524
+ ```
1525
+
1526
+ ### 17.2 Lexer
1527
+
1528
+ Hand-written rather than generated. `logos` handles token patterns well but does not model an indent stack, and the indentation rules are the part most worth controlling directly.
1529
+
1530
+ The lexer holds:
1531
+
1532
+ - a byte cursor and a line-start flag
1533
+ - a `Vec<u32>` indent stack, initialized to `[0]`
1534
+ - a bracket depth counter
1535
+
1536
+ At each line start it measures indent width, rejects tabs, and compares against the stack top. Greater width pushes and emits `Indent`. Lesser width pops repeatedly, emitting `Dedent` per pop, and errors if no stack entry matches. Blank lines and comment-only lines emit nothing. When bracket depth is above zero the whole procedure is skipped.
1537
+
1538
+ Tokens carry a `TextRange` of byte offsets, never line and column pairs. Line numbers are computed once, at diagnostic rendering time, from a line-index table.
1539
+
1540
+ ```rust
1541
+ pub struct Token {
1542
+ pub kind: SyntaxKind,
1543
+ pub range: TextRange,
1544
+ }
1545
+
1546
+ pub struct Lexer<'src> {
1547
+ src: &'src str,
1548
+ pos: TextSize,
1549
+ indents: Vec<u32>,
1550
+ bracket_depth: u32,
1551
+ at_line_start: bool,
1552
+ errors: Vec<LexError>,
1553
+ }
1554
+ ```
1555
+
1556
+ The lexer never fails hard. An unterminated string produces a token covering the rest of the line plus an error, so the parser keeps making progress and the editor keeps highlighting.
1557
+
1558
+ ### 17.3 Parser
1559
+
1560
+ Recursive descent for statements and declarations, Pratt parsing for expressions. The parser writes into a `rowan` green tree builder, producing a lossless CST that preserves whitespace and comments. The formatter and language server read the CST; everything past name resolution reads the typed AST view layered on top.
1561
+
1562
+ Error recovery synchronizes on three anchors: a `Dedent` to the enclosing block level, a statement-start keyword, and end of file. A parse error inside a view body recovers at the next sibling element, so one bad attribute does not lose the rest of the tree.
1563
+
1564
+ Expression parsing uses a binding-power table matching the precedence in section 5.2:
1565
+
1566
+ ```rust
1567
+ fn binding_power(op: SyntaxKind) -> Option<(u8, u8)> {
1568
+ Some(match op {
1569
+ T![|>] => (1, 2),
1570
+ T![??] => (4, 3), // right associative
1571
+ T![or] => (5, 6),
1572
+ T![and] => (7, 8),
1573
+ T![==] | T![!=] => (9, 9), // non-associative, checked after parse
1574
+ T![<] | T![<=] | T![>] | T![>=] => (11, 11),
1575
+ T![..] | T![..=] => (12, 12), // non-associative, section 5.2 level 5
1576
+ T![+] | T![-] => (13, 14),
1577
+ T![*] | T![/] | T![%] => (15, 16),
1578
+ _ => return None,
1579
+ })
1580
+ }
1581
+ ```
1582
+
1583
+ Equal binding powers on both sides mark non-associative operators; the parser detects a second occurrence at the same level and emits the chained-comparison diagnostic with its suggested rewrite.
1584
+
1585
+ ### 17.4 Name resolution and HIR
1586
+
1587
+ `vl-hir` lowers the CST into an arena-allocated tree with every name resolved to a `DefId`. Desugaring happens here, so later stages see a smaller language:
1588
+
1589
+ - `x |> f(a)` becomes `f(x, a)`
1590
+ - `a ?? b` becomes a match on the optional
1591
+ - `if` as expression becomes a match on the condition
1592
+ - string interpolation becomes a `concat` call over formatted parts
1593
+ - `derive` becomes a memoized function plus a dependency set computed from the free variables of its body
1594
+ - `load` becomes a state machine with four states and a cache key derived from the call arguments
1595
+ - an `auth role:x` guard becomes a `before` handler prepended to the route body
1596
+
1597
+ Views, routes, and endpoints reduce to ordinary functions and records by the end of lowering. Everything domain-specific lives in the front end, which keeps the backends small.
1598
+
1599
+ ### 17.5 Type checking
1600
+
1601
+ Local Hindley-Milner with no let-generalization across item boundaries, since item signatures are always annotated. Unification runs over a union-find of type variables. Row polymorphism is not needed, since records are nominal (§4.3); equality is structural but plays no part in unification.
1602
+
1603
+ Three checks run alongside inference:
1604
+
1605
+ 1. **Exhaustiveness** over union patterns, using the usefulness algorithm from Maranget's work on ML pattern matching, adapted to handle guards conservatively (a guarded arm never counts toward coverage).
1606
+ 2. **Fallibility**, verifying `?` appears only in fallible functions with a compatible error type, and that no `T!` value goes unused.
1607
+ 3. **Effect coloring**, marking every function that reaches IO transitively. The result drives async code generation without appearing in signatures.
1608
+
1609
+ ### 17.6 VIR and backends
1610
+
1611
+ VIR is a typed tree IR, not SSA. Application code is not compute-bound, and the optimizations worth doing (dead code, constant folding, request deduplication) are tree rewrites. Skipping SSA saves a large amount of implementation effort with little cost.
1612
+
1613
+ Three consumers:
1614
+
1615
+ - **`vl-interp`** walks VIR directly. It backs `vl run`, the REPL, and the test harness. Startup is under 50 ms on a mid-sized project, which makes the edit-test loop fast enough that the compiled backends are not needed during development.
1616
+ - **`vl-emit-ts`** emits TypeScript for views. Reactive state becomes signals, `load` becomes a request hook, and elements become framework component calls. The emitter targets a thin runtime library rather than a specific framework, so the framework is a swappable backend detail.
1617
+ - **`vl-emit-rs`** emits Rust for services. Routes become `axum` handlers, models become `sqlx` queries checked at build time, and `par` becomes `tokio::try_join!`. The generated crate is readable and committed to the build directory, which means a team can inspect what runs in production.
1618
+
1619
+ Generated code carries source maps back to Volaro spans so a runtime stack trace names `.vl` files and lines.
1620
+
1621
+ ### 17.7 Crate dependencies
1622
+
1623
+ | Crate | Use |
1624
+ |---|---|
1625
+ | `rowan` | lossless CST |
1626
+ | `la-arena` | HIR and VIR arenas |
1627
+ | `salsa` | incremental query framework for the LSP |
1628
+ | `rustc-hash` | fast hash maps |
1629
+ | `text-size` | byte offsets and ranges |
1630
+ | `ariadne` | terminal diagnostic rendering |
1631
+ | `serde` / `serde_json` | JSON diagnostics, schema import |
1632
+ | `tokio` | async runtime for generated services and the LSP |
1633
+ | `argon2` | password hashing, parameters pinned by `vl-auth` |
1634
+ | `insta` | snapshot tests for parser, formatter, and emitters |
1635
+ | `proptest` | round-trip property tests |
1636
+
1637
+ The compiler itself has no `unsafe` blocks, enforced by `#![forbid(unsafe_code)]` at every crate root.
1638
+
1639
+ ### 17.8 Testing strategy
1640
+
1641
+ - **Parser**: snapshot tests over a corpus of `.vl` files, plus a property test asserting that formatting is idempotent and that print-then-parse round-trips to an identical CST modulo whitespace.
1642
+ - **Type checker**: a `tests/ui` directory of files with expected diagnostics inline as comments, in the style of `rustc`'s UI tests.
1643
+ - **Backends**: differential testing. The same program runs on the interpreter and on each emitted backend, and the outputs must match.
1644
+ - **Auth**: an adversarial suite asserting that no reachable source program produces a cookie without `HttpOnly`, a password comparison that is not timing-safe, or a state-changing route without CSRF verification.
1645
+
1646
+ ---
1647
+
1648
+ ## 18. Diagnostics
1649
+
1650
+ Errors are the interface between the compiler and whatever is writing the code. Two formats, one source.
1651
+
1652
+ Human format:
1653
+
1654
+ ```
1655
+ error[E0412]: missing `key` on list
1656
+ ┌─ src/views/Feed.vl:19:5
1657
+
1658
+ 19 │ list posts as p
1659
+ │ ^^^^^^^^^^^^^^^ list needs a stable key expression
1660
+
1661
+ = fix: list posts as p key:p.id
1662
+ ```
1663
+
1664
+ Machine format, from `vl check --json`:
1665
+
1666
+ ```json
1667
+ {
1668
+ "code": "E0412",
1669
+ "severity": "error",
1670
+ "message": "missing `key` on list",
1671
+ "file": "src/views/Feed.vl",
1672
+ "range": { "start": 412, "end": 427 },
1673
+ "fix": {
1674
+ "description": "add a key expression",
1675
+ "edits": [{ "start": 427, "end": 427, "text": " key:p.id" }]
1676
+ }
1677
+ }
1678
+ ```
1679
+
1680
+ Every diagnostic carries at most one suggested fix, and the fix is a byte-range edit rather than prose. A generator applies it without re-reading the file. Where a fix is ambiguous the compiler emits no fix rather than a guess, since a wrong automatic edit is worse than none.
1681
+
1682
+ `vl explain E0412` prints the extended description with a correct and an incorrect example.
1683
+
1684
+ ---
1685
+
1686
+ ## 19. Tooling
1687
+
1688
+ | Command | Behavior |
1689
+ |---|---|
1690
+ | `vl new NAME` | scaffold a project |
1691
+ | `vl check` | typecheck, no output artifacts; `--json` for machine format |
1692
+ | `vl fmt` | canonical formatting, no options, exits non-zero on change with `--check` |
1693
+ | `vl run` | interpret, watch, hot reload |
1694
+ | `vl build` | emit TypeScript and Rust, compile, bundle |
1695
+ | `vl test` | run `std.test` suites on the interpreter |
1696
+ | `vl map` | print the project surface summary |
1697
+ | `vl migrate` | diff models against the database, print plan, apply |
1698
+ | `vl explain CODE` | extended diagnostic description |
1699
+ | `vl lsp` | language server over stdio |
1700
+
1701
+ The formatter has no configuration. Line width is 100. A single canonical form means generated and hand-written code are indistinguishable after formatting, and diffs never carry style noise.
1702
+
1703
+ ---
1704
+
1705
+ ## 20. Open questions
1706
+
1707
+ These are unresolved and should be settled before the grammar freezes.
1708
+
1709
+ 1. **Mutation inside closures.** Capture-by-value keeps reasoning simple but makes some event-handler patterns awkward. The alternative is capture-by-reference for `state` bindings specifically, which is a special case in exchange for ergonomics.
1710
+ 2. **Generics for user code.** The specification has generic types in the library (`list<T>`) but no way to declare one. Adding them costs syntax and inference complexity; omitting them forces duplication. A restricted form allowing type parameters on functions but not on records may be the right middle.
1711
+ 3. **Streaming responses.** `load` covers request and response. Server-sent events and WebSocket subscriptions have no construct yet, and bolting them onto `load` versus adding a `subscribe` block is undecided.
1712
+ 4. **The `distinct` boundary.** Newtypes prevent argument mix-ups but multiply conversion calls at the database and JSON edges. Automatic conversion at serialization boundaries would help and would also hide a real conversion.
1713
+ 5. **Whether `derive` dependency inference is legible.** Automatic dependency tracking removes a common bug and also removes a visible declaration. If a reader cannot predict when a `derive` recomputes, the feature has traded one confusion for another.
1714
+ 6. **Multi-tenancy in `auth`.** Roles are global. Organization-scoped roles are the common case in business software, and modeling them as a guard (`auth role:admin in:org`) versus leaving them to application code is open.
1715
+ 7. **Data-scope authorization.** §11 makes auth *guards* a compiler concern, but the *public scope* of a record — a `Post` is visible only where `published: true` — is not expressible. `db.posts.get(id)` reads any row by id, so a route can leak a draft even with every guard in place, and the §15 hand-written reference did exactly that (a `TASK.md` requirement: "a missing post and an unpublished post look the same … do not reveal that a draft exists"). Candidates: a `scope published: true` clause on the `model`, or requiring a public read to name its scope. This is the gap between "secure by construction" covering *who can call* and covering *what they can see*, surfaced by a real information-disclosure bug — it should feed v0.3.
1716
+ 8. **Disjunctive and composable authorization guards.** §11.2 can express one role set or one ownership predicate, but not a common policy such as “`admin` **or** the post's owner.” Feature 06 therefore had to fall back to `auth required` plus a hand-written handler condition. If that condition is omitted, the destructive route still compiles and deletes any matching row — the exact security-boilerplate failure goal 4 in §1.1 is meant to remove. Candidates include an explicit boolean guard form (`auth any(role:admin, owner:<expr>)`), a small `any`/`all` guard block, or a named reusable policy. Any design must define evaluation order, missing-resource behavior (including non-enumerating 403s), `session.user` typing, and how composed guards interact with CSRF and data scopes. Until resolved, hand-written authorization composition must be marked as a language gap rather than presented as idiomatic Volaro.
1717
+ 9. ~~**List growth: concatenation, append, or spread.**~~ **Resolved
1718
+ 2026-09-05.** There was no way to combine two `list<T>` values or add one
1719
+ element to an existing list — no operator (`+` on two lists lowered
1720
+ straight to the host's binary `+`, which is not list concatenation in
1721
+ every target), no `STD_FNS` entry, no spread syntax in the grammar.
1722
+ Found implementing feature 10 (infinite scroll): the reactive `load`
1723
+ re-fetches and replaces its bound value on every dependency change, so a
1724
+ paginated feed had no expressible way to keep earlier pages when a later
1725
+ one arrived — confirmed against emitted output, not just reasoned about.
1726
+ Decided over a `+` overload (would make one symbol mean two things, and
1727
+ `xs + x` vs `xs + ys` is genuinely ambiguous for a generating model —
1728
+ §4.6 is deliberate about avoiding exactly this kind of implicit,
1729
+ context-dependent meaning) and over spread syntax (new grammar for one
1730
+ operation, and `...` means nothing else in Volaro today): **`std.list`
1731
+ gains `concat(a, b)` and `append(xs, x)`**, ordinary functions that
1732
+ compose with the pipeline (`xs |> append(x)`) like `map`/`filter`
1733
+ already do, adding zero grammar. Resolving the primitive alone was not
1734
+ sufficient — see the companion decision below — and feature 10's own
1735
+ `Feed` view needed genuine compiler-bug fixes (`on mount` was
1736
+ AST-complete and precheck-passed but silently unemitted; the spec's own
1737
+ §6.2 expression-form `try`/`catch` had never been implemented) before it
1738
+ could actually use it. Full account: `views/10-infinite-scroll/DOGFOOD.md`.
1739
+
1740
+ **Companion decision: `load` does not gain an accumulate mode.** The
1741
+ alternative to a list primitive was extending `load` itself to keep
1742
+ prior pages instead of replacing them. Rejected: `load`'s `pending`
1743
+ region rendering *replaces the whole view body* while a fetch is in
1744
+ flight — correct for one first fetch, wrong for a later page, where
1745
+ already-loaded rows must stay visible with a loading indicator alongside
1746
+ them, not disappear behind a full-screen spinner. Redesigning `load`'s
1747
+ pending/error contract to support that was judged a bigger change than
1748
+ this pass's scope. The idiom instead: explicit `state` for the
1749
+ accumulator plus `on mount` (now real) and the feature's own event
1750
+ handlers drive fetches directly, giving full control over what stays
1751
+ visible — at the cost of losing `load`'s automatic request
1752
+ dedup/cache/cancellation-on-unmount for any feature that needs to
1753
+ accumulate, which is why this is recorded as a real trade-off, not a
1754
+ free win.
1755
+
1756
+ ---
1757
+
1758
+ ## 21. Milestones
1759
+
1760
+ | Milestone | Scope | Exit criterion |
1761
+ |---|---|---|
1762
+ | M0 | Lexer, parser, CST, formatter | Round-trip and idempotence property tests pass on a 5,000-line corpus |
1763
+ | M1 | HIR, type checker, interpreter | The worked example in section 15 runs, minus views |
1764
+ | M2 | Views, TypeScript emitter, token benchmark | 40-feature corpus implemented both ways, ratio measured and published |
1765
+ | M3 | `service`, `api`, Rust emitter | Worked example deploys and serves traffic |
1766
+ | M4 | `auth`, `vl-db`, migrations | Adversarial auth suite passes with zero reachable insecure programs |
1767
+ | M5 | LSP, incremental compilation, `vl map` | Sub-100 ms keystroke-to-diagnostic on a 6,000-line project |
1768
+
1769
+ M0 through M2 are the point where the central claim becomes testable. The density half is now measured ahead of M2 at **4.01×** on a 7-feature corpus (§14.3), above the 3:1 floor that would have called the design into question. What remains untested is whether an agent *writes* it correctly and whether "secure by construction" survives contact with an implementation — neither of which a token count can answer.
1770
+
1771
+
1772
+ ---
1773
+
1774
+ ## 22. Changelog
1775
+
1776
+ ### v0.2 — 2026-09-03
1777
+
1778
+ Ten inconsistencies found by implementing a 7-feature corpus by hand in both Volaro and a mainstream stack, and by writing a validator (`vlcheck`) against the grammar. Every change below closes a gap that made a real program unwritable or ambiguous.
1779
+
1780
+ **Blocking gaps — these made corpus features unwritable:**
1781
+
1782
+ 1. **§11.3 client surface was incomplete.** It defined four calls; a signup, a reset-confirm and a TOTP step need three more. Added `auth.sign_up`, `auth.confirm_reset` and `auth.verify_totp`, and gave all seven a documented return type and error union in a table. Without these, features that the spec's own §14 motivates could not be expressed.
1783
+ 2. **Item-route client names were undefined.** §10 showed `get /posts` and `get /posts/:id` as siblings but only ever called the collection. Added the naming rule (last non-parameter segment; singularised when the route has path parameters; `as` to disambiguate; duplicate names are a compile error). This unblocks generating a client for any route with a path parameter.
1784
+ 3. **No way to declare a service intentionally public.** A genuine public-read API always tripped the "more than half these routes are public" warning, training the reader to ignore it. Added `service X public` and per-route `auth none`, with `auth none` on a write route still warning.
1785
+
1786
+ **Semantic corrections:**
1787
+
1788
+ 4. **`try`/`catch` fell through to the success path.** The §14.1 sign-in example showed an error message being set *and* the success redirect running. Specified that a handled error terminates the `try` statement; code that must run regardless is placed **before** the `try`, or uses `spawn` for fire-and-forget. The example in §14.1 was right; the semantics were wrong. *(Amended below: an earlier draft of this fix added a `resume` keyword, since removed.)*
1789
+ 5. **Records were "structurally compared" (§4.3) and "nominal" (§17.5).** Both are true of different things: nominal typing, structural equality. Stated precisely, and noted that nominal typing is what makes `distinct` meaningful.
1790
+ 6. **`err` meant three things.** Separated the type `Err`, the constructor `err(...)`, and the reserved word `err`, and specified that `Err` is never record-literal constructed.
1791
+
1792
+ **Grammar corrections:**
1793
+
1794
+ 7. **The keyword list omitted words the spec itself uses as declaration heads** — `model`, `spawn`, `theme`, `list`. Split into a reserved set and a contextual set recognised by position, so a field named `state` or a function named `load` stays legal. (Reserved count settled at 29 in the v0.2 amendments below.)
1795
+ 8. **§3.4 required components to be `[A-Z]`** while every example used `col`, `row`, `text`. Made the case distinction meaningful: lowercase is a `std.ui` primitive element, uppercase is a `view`.
1796
+ 9. **§17.3's binding-power table omitted `..` and `..=`**, which §5.2 places at level 5. Added at (12, 12), non-associative.
1797
+ 10. **§1 said "no configuration files" while §7 and §13 required `volaro.toml`.** Narrowed the claim: one config file holding only what cannot be derived from source.
1798
+
1799
+ **Known follow-on:** the corpus adds an explicit `return` to every `catch` arm to work around finding 4. Those returns are now redundant. Removing them will shrink the Volaro side of the corpus and improve the measured ratio in §14.3, so the corpus should be simplified and re-measured rather than left as-is.
1800
+
1801
+ ### v0.2 amendments — 2026-09-05
1802
+
1803
+ One blocking gap, found by tracing the default signup path through the reference implementation rather than by writing a feature against it.
1804
+
1805
+ 1. **§11.3 had no way to complete email verification — so `verify_email true`, the default, was a dead end.** `sign_up` issues a verification token and leaves the account unverified; `sign_in` refuses an unverified account with `unverified_email`. Nothing in the seven-call client surface redeemed that token, so the compiler-owned auth API had no verification-completion path; ordinary model writes could still change the flag without redeeming a token. A project built on default settings therefore compiled, ran, accepted signups, and could not complete verification through the auth API, with no diagnostic for this missing step. The reference implementation's own tests concealed it: they marked the seeded user verified with a direct `UPDATE`, so the path was never exercised end to end. Added **`auth.confirm_verify(token:)` → `Session!`** with the error union `invalid_token`, `expired_token`, bringing the client surface to eight calls; §11.1 gains a matching bullet giving verification tokens the same single-use, hashed-at-rest, never-logged discipline as reset tokens, with a 24-hour life. This is the same class of gap as v0.2 finding 1 above and is recorded the same way.
1806
+
1807
+ **A successful confirmation also signs the user in.** `confirm_verify` returns `Session!`, so it establishes a session and rotates the session id exactly as `confirm_reset` does — the verification link is the last step before the application, not a step before a login form. This is a deliberate semantic choice and is stated in §11.3 rather than left to be inferred from the return type.
1808
+
1809
+ ### v0.2 amendments — 2026-09-03
1810
+
1811
+ Corrections made while catching `vlcheck` up to v0.2 and building the first outbound demo, before either encoded the rules.
1812
+
1813
+ 1. **§10 client naming is method-aware.** Finding 2 above keyed the generated name on the path alone, which made `get /x/:id` + `patch /x/:id` — ordinary CRUD — a name clash, and invalidated the §15 worked example. The name now depends on the HTTP method too: a collection `get` keeps the plural segment, every other collection verb and every item verb takes a `create_` / `update_` / `delete_` prefix on the singular, and an action segment (`…/:id/publish`) is used verbatim. The single-namespace clash rule stands; it just stops firing on well-formed REST.
1814
+ 2. **`resume` removed.** Finding 4 above added a `resume` keyword to opt into `try` fall-through. Its one motivating call site (the corpus password-reset request form) is better written by putting the shared statement before the `try`, which leaves `resume` with no use — a second way to say one thing, and pure hallucination surface. §3.5 drops it: **29 reserved words**, not the 30 the list held or the "Thirty-one" the prose claimed. §6.2 drops the sentence.
1815
+ 3. **`spawn` is legal in a view.** §12 and §8.5 now state that a `spawn` block may appear in a view body or event handler, and that — unlike `load` — it is not cancelled on unmount: it runs to completion and a failure only logs. This is the fire-and-forget form for view code, and the replacement for `resume` in the reset-request form.
1816
+ 4. **§9 parameter routing is split by method body** (the 11th §9 inconsistency, found building the outbound weather demo). §9 said any parameter in neither the path nor `query` is a compile error, but its own examples — `post check(text str)` with `path "/check"`, `post create_post(body NewPost)` with `path "/posts"` — put `text` / `body` in neither. Resolved: the compile error applies to `get` / `delete` / `head` only; for `post` / `put` / `patch` the unrouted parameters are the request body (`body` verbatim, others as a JSON object).
1817
+ 5. **§9 `timeout` has a default** (finding 12). It was optional with no stated value, so an omitted `timeout` meant "no timeout" — a call that hangs forever, silently. Now: `30s` when omitted, `timeout none` to opt out explicitly.
1818
+ 6. **No module-level value binding** (surfaced by the feature-12 dogfood, which wrote `let tabs = […]` at file scope). §7 listed only declarations at module scope but never said `let` there was disallowed, and `vlcheck` rejected it with a bare `E-PARSE`. Settled as a **non-goal**: a module is declarations only; a file-scope constant is a zero-parameter `fn`, a `let` inside the one consumer, `volaro.toml` / `env` for config, or a `type` for named cases. Reversible — a restricted `let` (const-expression RHS) can be added later if a real need appears. §7 now states this.
1819
+ 7. **Accessible compound primitives `dialog` and `tabs` / `tab`** (§8.8), from the feature 11/12 dogfood — a modal or tab set could be authored as valid Volaro but its keyboard, focus, portal and ARIA half was inexpressible, so the only expressible version was an inaccessible one. Resolved with **option A** of the v0.3 accessibility decision: two `std.ui` primitives that carry accessibility by construction. Option B (raw `role` / `aria-*` / key events) was rejected — it restores expressiveness by also making the broken version expressible again. The portal + `inert` + focus-save/restore runtime (`VL.overlay`) was built first and is decision-independent. Scope is deliberately two primitives behind two real features, not a component library; further widgets earn their place one real feature at a time.
1820
+ 8. **Comparison predicates in `where`** (§13.1), from the feature 10 dogfood — the worst defect class this project has found. `where` was equality-only with no stated limitation, so `where(created_at: cursor)`, written to mean "before this timestamp", passed `vlcheck` clean and `vlbuild` built it — compiling silently to `created_at = ?`. A build that reports success and returns the wrong rows, unlike every prior failure mode (no output, or a crash). Resolved by adding seven named predicate constructors (`lt`, `lte`, `gt`, `gte`, `ne`, `between`, `any_of`) as `where` values, each lowering to exactly one parameterized SQL fragment; the emitter now hard-errors on a `where` value it does not recognize instead of binding it as an opaque parameter, closing the same silent-success class for any future or mistyped predicate name. `where` still composes only by AND across named columns; a query needing `or` or nesting is a `db.raw` case. `db.raw` itself remains unlowered (a real gap, tracked separately) — this amendment fixes the common case, not raw SQL.
1821
+ 9. **List growth (§20.9) and two dead/unimplemented constructs found closing it**, from the feature 10 dogfood, fourth pass. `std.list` gains `concat(a, b)` / `append(xs, x)` — see §20.9 for why these and not a `+` overload or spread syntax. Closing the feature that motivated it also surfaced that `on mount` (§8.5) was AST-complete and `vlcheck`-clean but silently unemitted (a warning, not a build failure — dead code behind a passing check, the same shape as finding 8 above but one layer further from the network) and that the §6.2 expression-position `try`/`catch` (`let user = try load(id) catch not_found create_guest()`, shown in this very document) had never been implemented by the parser at all (`expected an expression, found 'try'`). Both fixed. A live-verified example clicking through a real running server now exists (`views/10-infinite-scroll/`); the `load` accumulate-mode alternative was considered and rejected — see §20.9's companion decision.
1822
+ 10. **Accessible inputs `label` / `aria_label` / `help` / `error`** (§8.9), first slice of the accessibility-by-default milestone. `input` was a plain `std.ui` element: the only way to name one was an adjacent `text` (not associated) or a `placeholder:` (not a label). `vlcheck` and `vlbuild` both accepted a nameless field, so the expressible form of a form was the inaccessible one — the same gap §8.8 closed for `dialog` / `tabs`, resolved the same way (option A: the primitive carries accessibility; no raw `aria-*` surface). `input` now requires `label:` or `aria_label:`; `E-INPUT-NAME` is an error naming the fix, and `placeholder:`-only does not satisfy it. `help:` and `error:` are compiler-wired to the control's `aria-describedby`, and a non-empty `error:` sets `aria-invalid="true"`. Bounded deliberately: live error announcement (`aria-live`), image alt text, page titles and heading / landmark structure are the rest of the milestone, not this amendment. `corpus/views/08-validated-form` was rewritten onto the syntax — its adjacent-`text` labels were the motivating case — so measured source moved and density was re-run; see `corpus/G1-RESULTS.md`. **Hardened 2026-09-06** (two review follow-ups, one patch): the name form is now *exactly one* of `label:` / `aria_label:` (`E-INPUT-NAME-DUP` when both are given), a statically empty / whitespace name is `E-INPUT-NAME`, and an at-runtime-empty name raises `render.empty_input_name` rather than rendering nameless. The generated `for` / `id` / `aria-describedby` ids are allocated **per rendered field instance** (lowering moved to a `VL.field` runtime helper), so a repeated component or a keyed-list row gets its own links and every `<label>` activates its own control — verified in real Chromium including inside a `dialog` and across a list reorder.
1823
+ 11. **Accessible images `alt` / `alt_todo`** (§8.10), second slice of the accessibility-by-default milestone. `img` was a plain `std.ui` element with `alt` a straight pass-through attribute — `img src:…` with no `alt` built and rendered, so the inaccessible image was the expressible default, the same gap §8.9 had for inputs. Resolved the same way (option A): `img` now declares its alternative through exactly one form of `alt:` — real text (`E-IMG-ALT` if absent or statically empty, `E-IMG-ALT-ONE` if two forms are given), `alt:decorative` for a presentational image (`alt=""`), or `alt:todo` / `alt_todo:"…"` for an unfinished description. The placeholder forms build with a navigable `alt` and **warn** (`W-IMG-ALT-TODO`); a new **`--release`** flag on `vlcheck` and `vlbuild` promotes them to `E-IMG-ALT-TODO` — the ship gate for unresolved markers. A meaningful `alt:` is lowered through `VL.reqAlt`, so a dynamic value (`alt:"{caption}"`) that resolves to empty raises `render.empty_alt` instead of silently degrading to `alt=""`. No measured corpus source uses `img`, so density is unchanged; only the spec / crib grew. Live error announcement (`aria-live`), page titles and heading / landmark structure remain the rest of the milestone.
1824
+ 12. **Page title, headings, and landmarks `page` / `h1`–`h6` / `main` / `nav`** (§8.11), third slice of the accessibility-by-default milestone. A build's root view is the page; there is no client-side router. New module-level `page title:<expr>` — written into `<title>` when literal, assigned to `document.title` at boot, so each full navigation carries its own title. A build that renders a page without one is refused (`E-PAGE-TITLE`); an empty title is `E-PAGE-TITLE`; a title that goes empty at load raises `render.empty_page_title`; two `page` blocks are `E-PAGE-DUP`. `h1`–`h6` emit real heading elements with the level independent of `size:`; an intra-view level skip is `W-HEADING-SKIP` and a second `h1` is `W-HEADING-MULTI-H1` — warnings only, per-view, so a reusable component starting at `h2`/`h3` is not flagged. `main` / `nav` emit the landmarks; one `main` per page (`E-MAIN-DUP`), and sibling `nav` regions need distinct `aria_label:` (`E-NAV-NAME`). When a `main` renders, the compiler tags it focusable and injects a keyboard-operable, focus-visible “Skip to main content” link — the author writes nothing. The 12 corpus features each gained a one-line `page title:`, so measured source moved and density was re-run 3.05× → 3.00× (`corpus/G1-RESULTS.md`). These structural checks do not prove accessibility or replace screen-reader testing. Live `aria-live` announcements are the remaining slice.
1825
+
1826
+ 13. **Live status and error regions** (§8.12). Added text-only `status message` with E-STATUS-SHAPE, compiler-owned polite/atomic semantics, and runtime string-or-nil validation. Existing input error regions remain exposed while empty and update retained text nodes without focus movement or repeated unchanged-message mutations. Browser Part 10 proves DOM behavior, not audible screen-reader output; no measured corpus source changed.
1827
+
1828
+ 14. **Composed-page landmark review** (§8.11), the accessibility-milestone review before styled-app work. The §8.11 landmark checks were per-view; the runtime composes one document from a page-root view that renders and repeats other views. Added a composition pass (runs when a module declares a `page`, follows component instantiation within the module from the page root, treats `if` / `else` branches as mutually exclusive, does not evaluate conditions): a second `main` across composed views or a reused `main` is `E-MAIN-COMPOSED`; two `<nav>`s that share one name or are both unnamed across composed views are `E-NAV-NAME`; one static `id:` rendered by more than one element is `E-ID-DUP`. Runtime: the auto-injected skip link is removed when its `main` target leaves the document and re-resolves the target at activation time. Cross-file `use` composition is not yet followed. Automated structural checks still do not prove accessibility or replace screen-reader testing; that pass is recorded separately. No measured corpus source changed.
1829
+
1830
+ 15. **Tailwind-backed styling Slice 1** (§8.7). Replaced the abandoned flat
1831
+ theme / `space unit:` / `raw_style:` sketch with nested `theme` categories,
1832
+ static `recipe button|input|card` declarations, the `card` grouping primitive,
1833
+ literal additive `class:`, and named space-token layout attrs. The compiler
1834
+ generates an explicit candidate manifest, invokes pinned upstream Tailwind at
1835
+ build time, parses the output and refuses unknown utilities. Runtime CSS is in
1836
+ a lower cascade layer; generated utilities are later; recipe-vs-class and
1837
+ recipe-vs-inline property conflicts are errors, so class token order is never
1838
+ claimed as precedence. Both dark selectors and reduced-motion output are
1839
+ generated. This implementation and its browser checks do not complete or
1840
+ replace the pending full-screen-reader acceptance protocol.