Skip to content

Language Core Specification ​

The seven kinds of definition a Kumiki program is made of, plus the expressions, statements and patterns they share. §1.1 gives the shape of a whole program; after that, read the layer you are about to write.

1.1 Overall Program Structure ​

A Kumiki program is a set of 7 kinds of definitions. There are no physical file boundaries; each definition is stored in a content-addressable graph as the following 4-tuple:

(layer, name, body, content-hash)

The textual representation is a projection from the graph and can be retrieved with kumiki view when needed (→ AI Editing).

program     ::= definition*
definition  ::= type-def | slot-def | effect-def | reducer-def | tile-def | fn-def | app-def

Definitions are unordered and may be forward-referenced. The compiler performs a topological sort.

1.1.1 List of Layers ​

LayerRolePurity
typeTypes / schemasPure
slotNamed global statePure initial value
effectPure record value representing a side effectPure (execution is separate)
reducermessage → slot change + effect emitPure (over the slot set)
tilePure projection from slot → UI treePure
fnAuxiliary pure functionsPure
appApplication entryDeclaration

These seven are the logic/data/UI core — what an author must learn to express behavior. Kumiki also has auxiliary presentational/meta definitions that sit alongside the seven without expanding that core: theme (Design Tokens), motion (The motion definition), and test (Testing). They are real top-level definitions but are not counted among the seven layers; the production-grammar EBNF above lists only the core.


1.2 Lexical ​

identifier  ::= [a-zA-Z][a-zA-Z0-9_-]*           ; max 32 characters, longest munch
qname       ::= identifier ('.' identifier)*     ; dot-separated fully qualified name
cap-name    ::= identifier ('.' cap-segment)*    ; a capability, whose segments name nothing in this language
cap-segment ::= identifier | keyword             ; so `telemetry.out` is a capability, not a syntax error
literal     ::= number | string | bool | unit
number      ::= int | float
int         ::= '-'? [0-9]+
float       ::= '-'? [0-9]+ '.' [0-9]+           ; the digits after the point are required
string      ::= '"' (escape | non-quote-char)* '"'
escape      ::= '\\' ('n' | 't' | 'r' | '"' | '\\' | 'u{' hex+ '}')
bool        ::= 'true' | 'false'
unit        ::= '()'
tuple       ::= '(' expr ',' expr (',' expr)* ')'  ; the value a Tuple types
comment     ::= '#' until-eol                    ; single-line comment only

- is both an identifier character and subtraction, and longest munch decides. page-size, base-url, on-401 and count-1 are one name each, which is why subtraction between a name and a literal is written count - 1.

Every spacing of count-1, and what an unresolved one reports

A - continues the identifier when an identifier character follows it, and ends it otherwise. count- 1 is also subtraction, because there the - has nothing to continue into; count -1 is subtraction because the space ended the name before the - was reached at all. A name written count-1 that resolves to nothing is E0103, and its message says so.

A # with whitespace on either side of it always starts a comment. It is the selector operator (§1.6.1) only in the tight form SaveBtn#new.

The exact condition, and where a # still comments

A # is the selector operator only when the character before it ends a value — an identifier character, or a closing ) / ] / } — and the character after it begins an identifier. Everywhere else, including #TODO at the start of a line and = 0# how many, it runs to the end of the line. A #id fragment therefore begins with a letter or _, which is what tile-ref already required.

Positions — line terminators, UTF-16 columns, and the BOM

A line is terminated by \n or \r\n; a lone \r is whitespace inside a line. A column counts UTF-16 code units, so an astral character advances it by two — the same convention the Language Server Protocol uses, and the one every consumer of a Kumiki position needs, since a patch splices a source line at column - 1. A leading byte-order mark is whitespace for the same reason: it is not part of the text, but it is part of the string being spliced, so it takes a column.

1.2.1 Operators ​

:=  =  ==  !=  <  >  <=  >=
+  -  *  /  %  ->
&&  ||  !            ; bool operators
&                    ; alias of `&&` (for ease of porting from other languages)
|                    ; type union / match arm separator (not bool OR — use `||`)
(  )  {  }  [  ]  ,  ;  :  .  #

Notes on bool operators:

  • Short-circuit AND: && (recommended) or & (alias, internally identical)
  • Short-circuit OR : || (recommended) or | (alias, but with a heuristic to avoid collision with match arms)
  • When writing | as bool OR, if the following token is the combination "Variant/_ + ->" (i.e. the start of a match arm), the parser prefers to treat it as an arm separator. If any other expression follows, it is interpreted as bool OR. As a safe measure, use || when in doubt.

1.2.2 Reserved Words ​

type  slot  effect  reducer  tile  fn  app
nominal  where  when  for  in  let  if  then  else  match  with
on  do  emit  cap  out  policy  retry
true  false
fresh  self  now  null

null is reserved but prohibited in programs (type error).

1.2.3 Design Decisions ​

  • Indentation-independent: leading whitespace is ignored
  • Newline is the statement separator: only inside do= can ; join multiple statements
  • Identifiers are at most 32 characters
  • An expression, type, pattern or tile tree is at most 256 levels deep: past that the parser reports a positioned error. No program in the examples or benchmarks comes near it.
  • Multi-line comments prohibited
  • Macros prohibited
What the 256-level limit is measured against

A construct that contains itself — a parenthesised expression, a list, a record, an if, a statement body, a tile call, a tuple pattern, a type application, a theme record — may not nest further, and neither may a left-associative chain build more than that many nodes (1 + 1 + 1 + …, x.trim().trim()…, a run of not or -). The limit is on the resulting tree, not on how the parser reached it: every stage after the parse walks that tree by recursion, so a chain parsed by a loop still exhausts the stack downstream.


1.3 Type Layer (type) ​

1.3.1 Syntax ​

type-def    ::= 'type' identifier ('(' type-param (',' type-param)* ')')? '=' type-expr
type-param  ::= identifier
type-expr   ::= primitive
              | nominal-type
              | record-type
              | union-type
              | generic-type
              | refinement-type
              | identifier
              | type-app

primitive   ::= 'Text' | 'Int' | 'Float' | 'Bool' | 'Unit' | 'Bytes' | 'Time'
nominal-type ::= 'nominal' type-expr
record-type ::= '{' field (',' field)* '}'
field       ::= identifier ':' type-expr
union-type  ::= variant ('|' variant)+
variant     ::= identifier ( '(' type-expr (',' type-expr)* ')' )?
generic-type ::= identifier '(' type-expr (',' type-expr)* ')'
type-app    ::= identifier '(' type-expr (',' type-expr)* ')'
refinement-type ::= type-expr 'where' pred-expr
pred-expr   ::= identifier ('(' literal (',' literal)* ')')?

refinement-type is recursive, so a type may carry more than one where, and the predicates conjoin: a value is accepted only when every one of them holds.

kumiki
type Handle = nominal Text where len-gt(3) where len-lt(7)

A Handle is longer than 3 characters and shorter than 7. The predicates are read through the chain a type denotes (§1.3.6, inv. 1), so they accumulate over a name as well as over one expression: on type Short = Text where len-lt(7), type Handle = nominal Short where len-gt(3) carries both. A generic that hands its parameter back is a link of the same chain (inv. 2): on type NonEmpty(T) = T where nonempty, NonEmpty(Short) carries len-lt(7) and then nonempty.

A value that fails is reported against the first predicate it fails, taking them in the order the chain is read, from the base outward. Inside one type expression that is the order they are written; across names it is the order the declarations lead through — Short's len-lt(7) comes before Handle's own len-gt(3) because Short is what Handle is declared over, wherever in the file either definition sits. That is the predicate the rejection names when a write discards a reducer's batch (batching) and the one whose message the error tile renders (Error Display).

1.3.2 Built-in Generic Types ​

Map(K, V)
Set(T)
List(T)
Option(T)         ; None | Some(T)
Result(T, E)      ; Ok(T) | Err(E)
Tuple(T1, ..., Tn)

1.3.3 Registered Refinement Predicates ​

nonempty
len-eq(N)         len-lt(N)         len-gt(N)
between(A, B)
positive          negative
email             url               uuid
regex("pattern")
one-of(v1, v2, ...)

Every one of them is a runtime check: the value is tested on its way into the slot, and a write that fails it is refused (Forms §5.6, Runtime §10.3.3). What each one tests:

PredicateHolds whenArguments
nonemptythe value is text with at least one character—
len-eq(N) / len-lt(N) / len-gt(N)the text's length is = N / < N / > None whole number, zero or more
between(A, B)the value is a number and A <= v <= B, both ends includedtwo numbers, A not above B
positive / negativethe value is a number and v > 0 / v < 0 — zero is neither—
emailthe text is local@host, the host carries a dot, and neither part holds whitespace or a second @—
urlthe text is absolute — a scheme, ://, then a host: https://kumiki.dev, not kumiki.dev—
uuidthe text is the 8-4-4-4-12 hexadecimal shape, any version, either case—
regex("p")p matches the value whole: the pattern is anchored at both ends, so regex("[0-9]{4}") refuses "AB1234"one text literal that compiles as a pattern
one-of(v1, ...)the value is one of the listed literalsat least one literal

A predicate is checked wherever in the type it is written, not only on the type itself: a record field, a union variant's payload, and a container's element (List / Set members, Map keys and values, Option's Some, Result's Ok / Err, a Tuple's members) are positions of the value, and a value is accepted only when every predicate holds at every position it has. On

kumiki
type Contact = {email: Text where email, age: Int where between(0, 120)}
slot form : Contact = {email: "[email protected]", age: 36}

form.email := "nope" is refused exactly as form := {email: "nope", age: 36} is, and so is age := 999 by way of either. The positions nest and recurse — List(Contact), a recursive type Tree = {label: Text where nonempty, kids: List(Tree)} — and the check walks the value, which is finite. A refused value is reported against the first predicate it fails, fields in declaration order, with the path to where it failed: (email at .email), (nonempty at .kids[1].label), (nonempty at .Found) for a variant's payload, (len-lt(6) at [1]) for a list element. The error tile renders that predicate's message (Forms §5.7.1).

A path is the list of steps from the slot's value inward — RefinementFailure.path in the runtime — and the report writes each step this way:

StepWrittenPosition
a field.emaila record's field
an index[1]a List element, a Tuple member
a variant.Found, .Pair[1]a union / Option / Result payload, by index when the variant carries several
a key.keys["k"]a Map key itself
an entry["k"]the Map value stored under k, as Kumiki's own index reads it
a member{"x"}a Set member

A key, an entry and a member hold the value as the key type reads it, so an Int key is [3] rather than ["3"]. The written form is for reading: the steps are distinct values, so a Map key is never mistaken for a field named keys, nor an entry under 1 for a list index.

Each position checks the value's shape before its predicates: a record and a Map are objects, a List and a Tuple arrays, a union / Option / Result value one of its variants. A value of the wrong shape there — a decoded {} where a list belongs, a payload missing a field — answers the position's predicates false, as the paragraph below says of any predicate, and is reported against the first of them.

Two positions are not walked. A Set member whose type does not come back out of the key the runtime stores it under — a type parameter, or Bytes — is not checked; a text, number or boolean member does, and so does a record, a variant or a tuple, which is stored as its JSON (Set({n: Text where nonempty}) is walked). And a generic that applies itself to a growing argument (type T(A) = {v: A, next: Option(T(List(A)))}) is a new type to lower at every level, so a slot whose refinement lies along one more than 32 levels deep is E0803 at build time rather than a check that stops partway. Distinct named types nest to any depth.

A predicate is a question about a value, so a value of the wrong shape answers it with false rather than raising: positive on text is false, and so is nonempty on a number. Written over a base of the wrong shape, then, a predicate refuses every value the slot can hold — Text where positive — and that is E0804. The len-* family, nonempty, email, url, uuid and regex need Text; between, positive and negative need Int, Float or Time; one-of compares strictly, so its literals need a Text base when they are text and an Int, Float or Time one when they are numbers. A generic's parameter is judged where the generic is applied: type NonEmpty(T) = T where nonempty is fine, and NonEmpty(Int) is E0804.

The set is closed, and a name outside it is a parse error. The arguments are checked too — a bound that is text, a fractional or negative length, len-lt(0) (shorter than every text), a pattern that does not compile, a range with nothing in it are all E0804, because a refinement no value can satisfy and one every value satisfies are the same defect. A registered predicate the toolchain does not lower is E0803 at build time rather than a check that silently passes.

Arbitrary Boolean predicates are prohibited. Reason: if the AI is forced to write proofs, the debugging loop breaks down.

1.3.4 Examples ​

kumiki
type UserId    = nominal Text where len-eq(36)
type Email     = nominal Text where email
type Url       = nominal Text where url
type Percent   = nominal Float where between(0.0, 100.0)
type User      = {id: UserId, name: Text where nonempty, email: Email}
type HttpError = {status: Int where between(400, 599), message: Text}
type LoadResult(T) = Idle | Loading | Loaded(T) | Failed(HttpError)

1.3.5 Type Canonicalization ​

Structurally identical types have the same content-hash. Only nominal produces a new hash.

A nominal type is identified by the name it is declared under, so two declarations over one base are two types even when their bodies are identical:

kumiki
type Cents = nominal Int where positive
type Yen   = nominal Int where positive

Cents and Yen do not accept each other — putting one where the other is required is E0201, and so is postId := userId on two nominal Text where uuid declarations. Comparing them is the same mistake and the same error: postId == userId and cents < yen are both E0201 (§1.9.4). An alias to a nominal names the same type (type Money = Cents), and a nominal written inline at a use site declares no name at all, so it is compared structurally like any other type expression.

The identity reaches the call that mints one too, which is where an id usually comes from: TypeName.fresh() is a TypeName and TypeName.parse(t) an Option(TypeName) (stdlib §2.4.1 / §2.4.3), so postId := UserId.fresh() is E0201 naming both types, and so is slot found : Option(PostId) = UserId.parse(t). fresh mints a uuid, so it is read for the nominal Text §2.4.1 scopes it to, and on a qualifier no uuid is a value of — a nominal Int, a record, a bare Int — the call is E0802 rather than a value of any type. A qualifier that names no type at all is E0117, and says nothing about the value either.

A type that carries no nominal name of its own meets any nominal declared over it, in both directions. That is what makes slot c : Cents = 1 legal without a construction form, and arithmetic yields the base (§1.9), so c := c + 1 stands.

A nominal declared over another nominal is a narrowing, and goes one way only:

kumiki
type Deep = nominal Cents

Every Deep was declared a Cents, so a Deep is accepted where a Cents is required; a Cents where a Deep is required is E0201. Int still meets both.

Converting between two unrelated nominals goes through the base they share, and a fn is where that is written down — the return type documents the destination, and the body has to have reached the base:

kumiki
fn toYen(c: Cents, rate: Int) -> Yen = c * rate
fn toUser(p: PostId) -> UserId       = p + ""

The arithmetic in the first lands on Int; the + "" in the second lands on Text. The identity body does not compile — fn toUser(p: PostId) -> UserId = p is E0201, because p is still a PostId. Nothing checks that such a fn converts anything; it records the intent, and the type of its body is all that is enforced.

A where refinement carries no identity of its own — type Positive = Int where positive is Int — and remains a runtime check (Forms §5.6) rather than a compile-time one: on type Volume = nominal Int where between(0, 11), volume := 50 is well typed, and the range is what validation decides.

1.3.6 Invariants ​

  1. A type denotes its body. An alias (type A = B), a nominal wrapper and a where refinement each stand for the type underneath, so following one has to arrive at a body.
  2. An alias chain must not return to itself. Following a definition to the next name — through an alias, a nominal, a where, or a generic that hands one of its parameters straight back (type Alias(T) = T) — must not reach a name already on the chain. type A = A, and the pair type A = B / type B = A, denote nothing: there is no body to reach. E0009.
  3. The chain ends at a structural type. A record, a union, a container and a primitive are types in their own right, so nothing written inside one continues the chain.
  4. A recursive type is legal, and is the accepted form of a type written in terms of itself:
kumiki
type Node  = {value: Int, next: Node}
type Tree  = {children: List(Tree)}
type Shape = Leaf | Branch(Shape, Shape)

Each reaches a structural type before it reaches itself, by invariant 3. Comparing two of them terminates because the relation is read co-inductively over the types as written: re-entering a pair already being compared answers yes, which is sound because the finite part of the comparison has been checked on the way down. Termination does not depend on the values being finite — Node above has none, its next being neither optional nor a container — and it is still a legal type.

  1. A type parameter is scoped to its definition and shadows a top-level definition of the same name: in type Alias(Cents) = Cents the body is the parameter, whatever Cents is declared elsewhere.

1.4 Store Layer (slot) ​

1.4.1 Syntax ​

slot-def    ::= 'slot' identifier ':' type-expr modifier? '=' init-expr
modifier    ::= 'transient' | 'volatile'
init-expr   ::= literal | record-literal | collection-literal | builtin-call
modifierMeaning
(none)Retained on hot reload; subject to persistence
transientDiscarded on hot reload
volatileNot written to the episode log; discarded on hot reload

At most one modifier: volatile already does everything transient does, so writing both says nothing volatile alone does not.

The initial value is required. A slot with no = would have to hold something before the program first writes to it, and the language has no value for that — no null, and no per-type zero.

1.4.2 Invariants ​

  1. All slots are global
  2. Mutation is only from a reducer's do=
  3. The initial value is a pure expression only (effects cannot be executed)
  4. Derived slots are prohibited (use the fn layer for derived computation). This includes the runtime's route slot, read directly or through a fn call — it is installed by the mount, after every initial value has been evaluated (E0304)

1.4.3 Examples ​

kumiki
slot todos       : Map(TodoId, Todo)              = {}
slot filter      : Filter                         = All
slot draft       : Text where len-lt(280)         = ""
slot session     : Option(SessionId)              = None
slot password    : Text                volatile   = ""
slot toast       : Option(Toast)       transient  = None

1.5 Side Effect Layer (effect) ​

1.5.1 Syntax ​

effect-def  ::= 'effect' identifier
                'cap' '=' capability-name
                'in'  '=' type-expr
                'out' '=' type-expr
                ('policy'      '=' policy-expr)?
                ('retry'       '=' retry-expr)?
                ('map-request' '=' map-expr)?

capability-name ::= identifier ('.' identifier)+
policy-expr     ::= 'latest' | 'latest-per-key' '(' expr ')' | 'queue'
                  | 'debounce' '(' duration ')' | 'throttle' '(' duration ')'
                  | 'once'
retry-expr      ::= 'none' | 'linear' '(' int ',' duration ')'
                  | 'exponential' '(' int ',' duration ',' float ')'
duration        ::= int 'ms' | int 's' | int 'm'
map-expr        ::= record-literal       ; conversion from high-level effect → low-level form

1.5.2 Semantics ​

  • An effect is a value (a pure record)
  • A reducer emits it with emit name(args)
  • Execution is performed by the runtime's effect dispatcher
  • A capability check is performed before execution (if undeclared, compile-time error)
  • The result is delivered to a reducer as effect-name.ok($value, $key) or effect-name.err($error, $key)
  • policy=latest-per-key(<expr>) and map-request are the effect's own expressions: both are applied to the effect's input, so $1 is the only bind, a slot and a fn are readable, and $route is not a name here
  • Both are checked like any other expression — an undefined name in the key is E0103, not a runtime failure at dispatch
  • A latest-per-key key is evaluated where the emit runs, so a slot it reads sees the reducer body's writes up to that statement and none after it (http.md §6.4)

1.5.3 Examples ​

kumiki
effect loadUser  cap=http.get
                 in=UserId
                 out=Result(User, HttpError)
                 policy=latest-per-key($1)
                 retry=exponential(3, 200ms, 2.0)

effect persist   cap=storage.write
                 in=Map(TodoId, Todo)
                 out=Result(Unit, Text)
                 policy=debounce(300ms)
                 map-request={key: "todos", value: $1}

1.6 Reducer Layer (reducer) ​

1.6.1 Syntax ​

reducer-def ::= 'reducer' identifier
                'on' '=' event-pattern
                'do' '=' do-block

event-pattern ::= ui-event | effect-event | timer-event | lifecycle-event | route-event
ui-event      ::= 'ui' '.' ui-kind '(' selector ')'
ui-kind       ::= 'click' | 'submit' | 'change' | 'input' | 'focus' | 'blur' | 'key' | 'hover'
selector      ::= tile-ref
tile-ref      ::= identifier ('#' identifier)?    ; TileName or TileName#id
effect-event  ::= identifier '.' ('ok' | 'err') '(' bind (',' bind)* ')'
timer-event   ::= 'timer' '(' duration ')'   ; fires this reducer every intervalMs
lifecycle-event ::= 'app.start' | 'app.stop' | 'app.error'
                  | 'app.visible' | 'app.hidden' | 'app.online' | 'app.offline'
                  | 'app.http-401' | 'app.http-403' | 'app.http-5xx'
                  | 'tile.mount' '(' identifier ')'
                  | 'tile.unmount' '(' identifier ')'
route-event   ::= 'route.enter' '(' string ')'
                | 'route.leave' '(' string ')'
                | 'route.error' '(' string ')'
bind          ::= '$' identifier

do-block      ::= statement-list
statement-list ::= statement ((';' | newline) statement)*
statement     ::= assign | emit | let-stmt | if-stmt | match-stmt | for-stmt | block
assign        ::= lvalue ':=' expr
emit          ::= 'emit' identifier '(' (expr (',' expr)*)? ')'
let-stmt      ::= 'let' identifier '=' expr
if-stmt       ::= 'if' expr 'then' stmt-body ('else' stmt-body)?
match-stmt    ::= 'match' expr 'with' ('|' pattern '->' stmt-body)+
for-stmt      ::= 'for' identifier 'in' expr stmt-body
block         ::= '{' statement-list '}'
stmt-body     ::= block | statement-list   ; newline-based. Stops at `else` / `|` / `}`
lvalue        ::= path
path          ::= identifier
                | path '.' identifier        ; field path (Option/Result auto-unwrapped)
                | path '[' expr ']'          ; index/key path

Forms of stmt-body:

  • Single statement: if cond then x := 1 else x := 2
  • Multiple statements (block): if cond then { x := 1; y := 2 } else x := 3
  • Multiple statements (newline): continues with newline/; separators until reaching else / | / } / EOF

In other words, you can mix one-line layout and block layout. When writing in newline-based style, you only need to insert newlines so that the following statements stop at the right position before the next keyword (such as else).

Multiple subscriptions to the same event are allowed. When two or more reducers declare the same on= pattern (e.g. both subscribe to ui.click(SubmitBtn)), all of them fire in definition order — see §1.6.4 Invariant 3.

1.6.2 Selectors ​

A selector is TileName or TileName#id only (CSS attribute selectors have been removed).

kumiki
reducer add     on=ui.click(AddBtn)         do= ...
reducer toggle  on=ui.click(TodoRow)        do= ...
reducer login   on=ui.submit(form#login)    do= ... # ❌ 'form' is a built-in element, not a tile name

To bind events directly to built-in elements (button, input, form, etc.), create a wrapper tile:

kumiki
tile LoginForm = form(...) {id: "main"}

reducer doLogin
    on=ui.submit(LoginForm)         # reference by tile name
    do= emit login({...})

TileName#id narrows a subscription to dispatched elements whose {id} prop equals id. A bare TileName reducer still fires for every instance; an #id-scoped reducer fires only when the runtime sees a matching id. Use it to make intent explicit when several tiles wrap the same built-in element, so the wrong one cannot quietly trigger the wrong reducer:

kumiki
tile NewForm  = form(submit-text="add",  text=draft.new)  {id: "new"}
tile EditForm = form(submit-text="save", text=draft.edit) {id: "edit"}

reducer add  on=ui.submit(NewForm#new)   do= ...   # only the "new" form
reducer save on=ui.submit(EditForm#edit) do= ...   # only the "edit" form

The {id} prop is also rendered as the element's native HTML id attribute. Multi-reducer rules from §1.6.4 Invariant 3 apply unchanged: a bare-TileName reducer and an #id-scoped reducer that both match the same event still run in definition order.

A selector on a container reaches the descendants that fire the event. Most container kinds fire nothing themselves — a box has no keydown, no focus and no submit of its own — so ui.<ev>(<Container>) is wired onto every descendant whose kind is in the allowed set for <ev> (§W0212 lists them). ui.hover is the unrestricted case: it is wired onto the container and onto each descendant, because mouseenter does not bubble.

How the descendant is written makes no difference. A body that names a child tile is walked exactly as an inline one, to any depth, so these two are one program:

kumiki
tile Leaf   = input(placeholder="type")
tile RefBox = box(Leaf)                          # a tile reference
tile Inline = box(input(placeholder="type"))     # the same tree, spelled out

reducer typed on=ui.key(RefBox) do= ...          # wired onto the input, both ways

The tiles a subscription reaches are decided by the path a descendant is rendered under, not by the descendant alone: a Leaf rendered beside RefBox is not inside it and receives no handler from it. When several enclosing tiles subscribe to one event on one descendant, §1.6.4 Invariant 3 applies as usual — every match fires, in definition order.

A handler written for that same event on a call site of the descendant tile (Btn {onClick: r}) joins the lifted subscriptions rather than replacing them: r and every enclosing tile's reducer all fire, in definition order (§1.7.3). So a subscription on a container reaches every descendant that fires the event, a button with its own handler included: ui.click(TodoRow) on a row holding a checkbox and a delete button runs on both. Subscribe to the narrowest tile the event means — ui.click(TodoCheck) with tile TodoCheck = check(...) — rather than to the row around it.

1.6.3 lvalue Semantics ​

An lvalue is a path, and you can directly mutate nested fields or the contents of an Option. The compiler expands this into an immutable update.

The steps a path may take are a closed set: a field (.name), an index ([key]), and .get on an Option / Result. Nothing else is an lvalue — in particular a stdlib member is not one, and name.length := 9 is E0602 rather than a write. There is nothing for such a write to mean: a member derives a value from the receiver, so there is no place in the receiver for the assignment to land.

kumiki
# These reducer statements:
todos[id].done := true
editor.title := "New"
editor.get.body := "Body"        # via Option (compiler expands to Option.map)

# are internally expanded as:
todos := todos.update(id, $1.copy(done=true))
editor := editor.copy(title="New")
editor := editor.map($1.copy(body="Body"))

Going via .get is safe: assigning when the Option is None is a no-op (does not panic). If you want to explicitly panic, write editor := Some(editor.get.copy(body="Body")). .get is the same polymorphic unwrap it is when read (Standard Library §2.2.4), so a Result behaves alike: the write edits an Ok payload and skips an Err. Note that only the assignment is safe — a right-hand side that reads editor.get while the Option is None still panics.

An index step names a place in its receiver, and what it names depends on the receiver:

  • Map(K, V) — the entry at the key. m[k] := v inserts or replaces it; m[k].f := v writes a field of the entry, and on an absent k writes nothing, as the update it expands to does. A read m[k] at an absent k is a panic (Lifecycle §7.2.2), as a List index past the end is: there is no V for it to answer. m.get(k) is the read that answers None there instead. In a tile the read runs during render, so an absent key panics that render, the first one included: the nearest error-boundary shows its fallback, and with none the built-in panic display replaces the page (Lifecycle §7.3). A tile reads a key that may be absent through m.get(k) or m.get-or(k, d).
  • List(T) — the element at the position, and the index is an Int (anything else is E0201). xs[i] := v replaces the element at i in a new List of the same length, and xs[i].f := v writes through it; every level keeps its shape. An index that names no element — i past the end, or negative — is a panic (Lifecycle §7.2.2): the reducer's writes roll back and app.error runs. A read xs[i] panics at the same indices, so the two sides of := agree; xs.get(i) is the read that answers None there instead. To grow a list, write xs := xs.push(v).
  • Set(T) — nothing. A Set has membership and no places, so s[x] := v is E0602; membership changes through .add / .remove / .toggle (Standard Library §2.2.2).

The name is dispatched, not reserved: on a record that declares a field named get, rec.get.title := v writes that field. Both sides resolve .get by the same rule — a record's own field wins, otherwise it is the unwrap.

A bind= target reaching through .get is a read as well as a write, so it panics while the value is empty: input(bind=draft.get.title) with draft = None fails during the first render and the app does not mount. Reach such a control through a match on the Option, the way any other .get read is reached.

.copy(field=value, ...): a shortcut for an immutable update of a record. It looks like a method call, but internally the named args are collected and expanded into recordCopy(rec, {field: value, ...}). You can update multiple fields at once:

kumiki
editor := editor.copy(title="New", body="Body", updatedAt=now)
issue.copy(status=Done, priority=High)

1.6.4 Invariants ​

  1. Pure function: input = (slot set, event payload), output = (new slot values, emit set)
  2. Direct execution of effects is not allowed. Only emission via emit
  3. Multiple reducers matching the same event run in definition order
  4. Writing to the same lvalue path is allowed at most once within a single reducer (path-shape granularity, E0601)
    • Duplicate detection is done by the shape of the path. issues[k].status and issues[k].updatedAt are different paths → can coexist
    • Writing the same shape twice is a violation: x := 1; x := 2 ✗
    • In the mutually exclusive branches of if/match, each branch is counted independently. Writing the same shape in both the then and else is OK (at runtime only one of them runs)
    • Examples:
      • issues[iid].status := s; issues[iid].updatedAt := now ✓ (different field paths)
      • if cond then x := 1 else x := 2 ✓ (mutually exclusive branches)
      • x := 1; x := 2 ✗ (same path, sequential)
      • if cond then x := 1 else x := 2; x := 3 ✗ (same path again after combining mutually exclusive branches)
    • Even with the same shape, different index values (m[k1] and m[k2]) cannot be statically decided, so they are treated as 1 write (the stricter side). If you want to update multiple keys, use a for loop
  5. Calling fn is allowed (safe because it is pure)
  6. The batch commits all-or-nothing: if any slot's new value violates its type's refinement, the entire reducer application is discarded — no slot write, no emit, no stop-timer — and the rejection is reported (see batching). A reachable bound is the program's business: write the guard.
    • volume := volume + 1 on Volume = nominal Int where between(0, 11) ✗ at 11 (rejected and reported)
    • if volume < 11 then volume := volume + 1 ✓
  7. A slot read reads the value the body most recently wrote to that slot, wherever the read sits: in a match arm, in a statement-level if / match, in the body of a let … in, in a method's predicate or element lambda (names.filter($1 == noteKey)), or directly in the body. After noteKey := "b", every read of noteKey that runs later in that application is "b". A read that runs before any write to that slot in this application reads the value the slot held when the reducer started.

1.6.5 Positional Binding ​

SyntaxMeaning
$1, $2, ...the bind order of an effect-event; within a fn, the argument order; within a tile, the tile's in= argument ($1 only — a tile takes a single positional argument)
$elthe {...} props of the tile that fired the event
$eventthe event payload
$routethe Route at route.enter / route.leave / route.error, and in a link's prefetch target — nowhere else (Routing §3.4). Any other reducer reads the route slot
$nowthe current time

An effect-event trigger cannot bind these names. The compiler declares $el, $event and $route in every reducer body, seeded from whatever the trigger's payload carries — so a bind that takes one of them is a second declaration of the same name, and on=load.ok($el, _) is E0121. The numbered binds stay bindable; nothing else declares one.

A bind list's names must be distinct. A bind list names the payload's positionals in order, so two binds naming one thing leaves the other positional with no name to read it by — on=load.ok(dup, dup) is E0123. _ is exempt however often it is written, and occupies a position rather than skipping one: it is the spelling for a positional the reducer does not read.

The first bind has the type the effect's out= declares. On out=Result(T, E), load.ok($v, _) binds $v : T; any other out= is the whole value on .ok. So session := $v into a slot of another type is E0201, and a member call on $v answers from T as it would on a slot of that type. On .err, an effect on a storage / session / indexed capability binds $e : Text: those effects deliver the failure's message, also when the map-request or a host provider throws (Storage Effects), so problem := $e stores it and $e.message is E0108. Their out= must say so — Result(T, Text); any other E is E0306. .err on any other capability is not typed from out=: an HTTP handler delivers its {status, message, body} record and a custom capability's value is its provider's (Standard Capabilities), so reads of $e there are not checked. The second bind (the request key) and a built-in effect's result have no declared type either.

In a fn, a positional is a parameter. $1 is the first parameter and $2 the second — the same value, with the type the parameter declares — so fn plus(a: Int, b: Int) -> Int = $1 + $2 is a + b. There is one positional per parameter: $1 in a fn with none, or $2 in a one-parameter fn, is an undefined reference (E0103). A fragment inside the body binds its own $1 / $2, which shadow the fn's: in fn dbl(xs: List(Int)) -> List(Int) = $1.map($1 * 2) the receiver is xs and the fragment's $1 is each element.

$1 in a tile requires in=. A tile may reference $1 (e.g. todos[$1]) only if it declares an in= argument type — tile TodoRow in=TodoId = … todos[$1] …. Using $1 in a tile with no in= is an undefined reference (E0103): there is no positional argument to bind. See Examples.

1.6.6 Examples ​

kumiki
reducer addTodo
    on=ui.submit(NewTodoForm)
    do= let id = TodoId.fresh()
        todos[id] := {id, text=draft, done=false, createdAt=now}
        draft := ""
        emit persist(todos)

reducer toggle
    on=ui.click(TodoCheck)
    do= todos[$el.todoId].done := not todos[$el.todoId].done
        emit persist(todos)

reducer loaded
    on=loadUser.ok($user, $id)
    do= users[$id] := Loaded($user)

reducer editTitle
    on=ui.input(TitleInput)
    do= editor.get.title := $event.value

1.6.7 Scoping and Shadowing ​

A name declared over one already in scope shadows it: every read below the declaration is the inner binding, and the outer one is back once the scope that declared the inner one ends. Every declaring form binds this way — a let statement, a for bind, a match-arm pattern, and the let … in expression (§1.9).

The scopes are the reducer body and every nested statement body inside it: a for body, each branch of an if, and each match arm including a catch-all _. A binding declared in one of those ends with it.

A declaration's own right-hand side is evaluated before the name it declares is in scope, so let n = n + 1 reads the binding it shadows rather than the one being declared.

Binds written side by side are peers rather than a shadowing pair, because nothing nests them: two binds of one pattern (Both(a, a)) are E0122, and an effect-event bind list is the same shape (E0123). A name repeated there is not a shadow — it is a value the pattern or the trigger has left with no name to read it by.

A let may take one of the positional bindings. Those are declarations like any other, so the let wins for every read below it — including a $route in a reducer whose trigger binds none, which is that let's value and not a payload read (E0119). An effect-event bind is the one position that cannot take one of the names (E0121): a bind list names the payload's positionals, so a bind that took $el would have nowhere left to put the positional it stands for.

kumiki
reducer shadowing
    on=ui.click(Btn)
    do= let $el   = "chosen"                # wins over the positional binding
        let label = "first"
        let label = label + "+second"       # the right-hand side reads the `let` above
        for label in tags { hits := hits + 1 }   # binds inside the loop only
        note := label + "/" + $el           # "first+second/chosen"

1.7 View Layer (tile) ​

1.7.1 Syntax ​

tile-def     ::= 'tile' identifier
                 ('in' '=' type-expr)?
                 ('sub-routes' '=' route-map)?
                 ('error-boundary' '=' identifier)?
                 ('scroll-restoration' '=' bool)?
                 '=' tile-expr

tile-expr    ::= tile-call
               | match-expr
               | control-flow

tile-call    ::= identifier '(' (tile-arg (',' tile-arg)*)? ')' ('{' prop (',' prop)* '}')?
tile-arg     ::= (identifier '=')? expr
prop         ::= identifier ':' expr

control-flow ::= when-expr | for-expr | if-expr
when-expr    ::= 'when' '(' expr ',' tile-expr ')'
for-expr     ::= 'for' identifier 'in' expr tile-expr
if-expr      ::= 'if' expr 'then' tile-expr 'else' tile-expr

match-expr   ::= 'match' expr 'with' match-arm+
match-arm    ::= '|' pattern '->' tile-expr
pattern      ::= identifier
               | identifier '(' bind (',' bind)* ')'
               | '_'

( … ) vs { … } — arguments/children vs props:

  • ( … ) is the argument & children list: positional child tiles (column(A, B)), value arguments (heading("Hi")), and named arguments (button(text="Save", onClick=r), input(bind=draft)). A child tile or another tile call goes here.
  • { … } is the props block: key: value pairs only — style/layout/ARIA props and event-handler bindings ({pad: "lg", gap: "md"}, {todoId: $1}, {onClick: r}). It contains no tile calls and no children. Writing a tile call inside { … } (e.g. link(to="/x") {text("Home")}) is a parse error.
  • A tile's label/content is passed in ( … ): it is the first positional value-arg for the text builtins (text("Home"), heading("Hi"), code("…")) — a named argument is a prop wherever it is written, so heading(level=2, title) says title and level stays a prop — and a named arg for button (button(text="Save")). link and label take either: their first positional argument (link("Home", to="/x"), label("Name")), or text= when none is written (link(to="/x", text="Home")) — so, as for editable, text= beside a positional argument is never read. (link additionally accepts the older {text: "…"} prop form, which most existing examples use; all compile to the same node.) An argument written as content that the builtin never reads — a second positional argument, text= on a text builtin with no positional one, or text= beside a positional one on link / label / editable — is E0129: text= is the label argument of button / link / label / editable, and a prop on the text builtins.
  • A positional argument of a builtin other than the value builtins (text, heading, markdown, code, editable, label, link, image, icon) renders only when it is a tile — a tile-expr or the name of a defined tile. A value there — column(text("a"), 42), a slot, column(let x = 42 in Card(x)) — renders nothing and is E0128: show it with a tile (text(n.show)), write it where it is used, or compute it in a fn. Where a value belongs (a value builtin's content, a user tile's input, a named argument) a let is a value, and is checked like one. A tile body and a when / if / for / match arm are a tile-expr themselves, so a let there is a parse error.

Semantics of when(cond, tile):

  • cond is true → render tile
  • cond is false → omit that child from the tree (no effect on siblings)
  • If the parent tile is column(A, when(c, B), C), then with c=false, [A, C] is rendered
  • Because the runtime skips null/undefined children, when is a safe way to produce a "blank"

Value context vs tile context for match:

  • A match within the positional arguments of the text/heading/markdown/label/link/image/icon builtins is treated as a value expression (MatchExpr). Each arm returns a value (Text, Int, etc.)
  • A match within any other tile argument (column, row, card, etc.) is treated as a tile expression (TileMatch). Each arm returns a tile
  • Example: text(match m with | A -> "a" | B -> "b") ← value match
  • Example: column(match xs with | Loaded(ys) -> ... | None -> spinner()) ← tile match

1.7.2 Invariants ​

  1. Pure function: input = (slot set, in argument), output = UI tree
  2. Slot writes are not allowed
  3. Effect emit is not allowed
  4. Direct recursion is prohibited. Mutual recursion only when depth can be proven at the type level
  5. The iteration target of for is only Map.keys, Set.to-list, or List
  6. Within the value expressions of tile properties {...}, reading slots is allowed (for the purpose of fixed capture of event-handler arguments)
  7. Calling fn is allowed

1.7.3 Event Handler props ​

An event handler takes a reducer name, and may be written on a user tile as well as on a builtin. On a user tile it is a prop like any other: it is merged onto the node that tile renders — onto each of them, when the body is a for that renders a list — so Btn(onClick=tap) and Btn() {onClick: tap} are the same wiring, and whether it fires is a question about what Btn renders. Merged means joined: the handler adds to whatever that node already dispatches for the event — a handler written on the builtin itself, one written on another call site the node is the root of, and every ui.<ev>(<Tile>) subscription lifted onto it (§1.6.2). Each matching reducer runs once, and all of them run in definition order, as §1.6.4 Invariant 3 says of any reducers matching one event; where a handler happens to be written does not decide what runs first. W0213 asks that question at both kinds of call site, by walking the tile's render tree: a handler on a user tile that renders no kind able to fire it is reported the same way one written on an inert builtin is. A named argument is never the tile's input — that is the positional one — so a tile declaring in= still takes it: Row(onClick=tap, label) passes label as $1.

kumiki
button(text="Save", onClick=saveTodo) {todoId: $1}

With onClick=saveTodo, the reducer saveTodo is called on click. {todoId: $1} is delivered to the reducer as $el.todoId.

The name is resolved in the reducer namespace and only there, whatever its capitalisation: onClick=Bump binds the reducer Bump, and a name that names no reducer is E0102 — a tile written there included. What capitalisation decides is the shape the parser gives the name, and nothing else; it is not a way to say which layer is meant. A capitalised name is a tile call when it is a named argument of a builtin that takes tiles (box, button, input, modal, form), and a variant tag everywhere else: in a props block, as a named argument of a value-arg builtin such as link, and as a named argument of a user tile.

Because those shapes carry no arguments, the bare name and the argument-less call are one and the same after parsing — onClick=Bump, onClick=Bump() and onClick=Bump {} are the same node, and all three bind the reducer. A value that carries arguments is not a name and is E0201.

1.7.4 Examples ​

A tile that takes a positional argument declares its type with in= and reads the argument as $1. $1 is available only when in= is declared — using $1 in a tile with no in= is an undefined reference (E0103). Callers pass the argument positionally: TodoRow(id).

kumiki
tile TodoCheck in=TodoId = check(value=todos[$1].done) {todoId: $1}

tile TodoRow  in=TodoId
              = row(
                  TodoCheck($1),
                  text(todos[$1].text) {strike: todos[$1].done},
                  button(text="x", onClick=remove) {todoId: $1})

tile TodoList = column(
                  for id in todos.keys
                    when(matchFilter(todos[id], filter),
                      TodoRow(id) {key: id.show}))

tile App      = page(
                  heading("Todos"),
                  NewTodoForm,
                  TodoList,
                  text(itemsLeft.show + " items left"))

1.8 Function Layer (fn) ​

1.8.1 Purpose ​

To reuse pure auxiliary computations under a name. Callable from tile / reducer / other fn.

1.8.2 Syntax ​

fn-def      ::= 'fn' identifier
                '(' (fn-param (',' fn-param)*)? ')'
                ('->' type-expr)?               ; return type (inferred if omitted)
                '=' expr

fn-param    ::= identifier ':' type-expr

1.8.3 Invariants ​

  1. Pure function: input = arguments only, output = value only
  2. Reading/writing slots is prohibited (receive them via fn arguments)
  3. Effect emit is prohibited
  4. lvalue not allowed (no assignment)
  5. Calling other fn is allowed, direct recursion is prohibited, mutual recursion only when depth can be proven at the type level

1.8.4 Examples ​

kumiki
fn matchFilter(t: Todo, f: Filter) -> Bool
   = match f with
       | All     -> true
       | Active  -> not t.done
       | Done    -> t.done

fn itemsLeft(ts: Map(TodoId, Todo)) -> Int
   = ts.filter(not $2.done).size

fn visiblePosts(posts: Map(PostId, LoadResult(Post)), tag: Option(Text)) -> List(PostId)
   = posts.entries
          .filter(matchPostTag($2, tag))
          .sort-by(loadedAt($2))
          .map($1)

fn matchPostTag(lr: LoadResult(Post), tag: Option(Text)) -> Bool
   = match (lr, tag) with
       | (Loaded(p), Some(t)) -> p.tags.find($1 == t).is-some
       | (Loaded(_), None)    -> true
       | _                    -> false

1.8.5 Calling from tile / reducer ​

kumiki
tile TodoList = column(
                  for id in todos.keys
                    when(matchFilter(todos[id], filter), TodoRow(id)))

tile Counter  = text("Left: " + itemsLeft(todos).show)

reducer normalize
    on=ui.click(NormalizeBtn)
    do= todos := normalizeAll(todos)

fn normalizeAll(ts: Map(TodoId, Todo)) -> Map(TodoId, Todo)
   = ts.map($2.copy(text=$2.text.trim))

1.8.6 Partial Application and Higher-Order Functions ​

Since there are no lambdas, passing higher-order functions uses either a "fn name" or an "expression fragment":

kumiki
items.map(double)         # registered fn name
items.map($1 * 2)         # expression fragment ($1 is the element)
items.filter(matchFilter($1, filter))  # embed a fn call in an expression fragment

Partial application is written explicitly (no currying):

kumiki
fn isActiveOnly(t: Todo) -> Bool = matchFilter(t, Active)
items.filter(isActiveOnly)

A fn name in a fragment position is the call the method makes with the fragment's positionals: items.map(double) is items.map(double($1)), and xs.fold(0, add) is xs.fold(0, add($1, $2)). It is the only position a bare fn name is right — a fn is not a value, so label written where a value goes is E0127, and the call is label(). The positions, and how many positionals each binds, are listed under E0127.


1.9 Expression Language ​

The common expressions used in the right-hand side of a reducer's do=, inside a tile, and in the body of a fn.

expr        ::= literal
              | qname                          ; slot, let-binding, fn-arg, builtin reference
              | expr '.' identifier            ; field access
              | expr '[' expr ']'              ; index
              | expr binop expr
              | unop expr
              | 'if' expr 'then' expr 'else' expr
              | 'match' expr 'with' match-arm+
              | 'let' identifier '=' expr 'in' expr
              | call
              | record-lit
              | collection-lit
              | '(' expr ')'

call        ::= qname '(' (expr (',' expr)*)? ')'
record-lit  ::= '{' (field-init (',' field-init)*)? '}'
field-init  ::= identifier '=' expr | identifier
collection-lit ::= '[' (expr (',' expr)*)? ']'
                 | '{' (entry (',' entry)*)? '}'
entry       ::= expr ':' expr

match-arm   ::= '|' pattern '->' expr
pattern     ::= identifier                            ; a union variant, or a binding name
              | identifier '(' bind (',' bind)* ')'   ; variant with payload binds
              | '(' pattern (',' pattern)* ')'        ; tuple
              | '_'                                   ; wildcard

binop       ::= '+' | '-' | '*' | '/' | '%'
              | '==' | '!=' | '<' | '>' | '<=' | '>='
              | '&' | '|'
unop        ::= '-' | '!'

An if and a match evaluate to one of their branches, so every branch has to fit where the expression lands. p := match ou with | Some(id) -> id | None -> p is E0201 at the Some arm when ou is an Option(UserId) and p a PostId — each arm is read with the types its pattern binds, exactly as p := ou.get-or(p) is. Where nothing declares a type (a let, an operand), the expression has its branches' common type. When the branches disagree, that is the base they share, with the nominal dropped: a UserId branch beside a PostId branch gives Text. The expression has no type, and nothing is reported against it, only when the branches share no base or one branch's type cannot be decided.

1.9.1 Prohibitions ​

  • Lambda expressions prohibited
  • try/catch prohibited
  • null / undefined prohibited
  • while loops prohibited
  • Assignment expressions prohibited (:= is a statement and cannot be used within an expression)
  • Literal patterns prohibited. A match pattern is a union variant, Variant(binds), a tuple, or _ — only. Patterns matching against a literal value (match s with | "Overdue" -> … | "Today" -> …, or numeric/bool literals) are not supported and fail to parse. match is for destructuring a union/variant, not for branching on a Text/Int/Bool value. To branch on a value, use if/else (or chained if), or model the cases as a union type and match on that:
kumiki
# ❌ literal patterns — not supported
match label with | "Overdue" -> red | "Today" -> amber | _ -> gray

# ✅ branch on a value with if/else
if label == "Overdue" then red else if label == "Today" then amber else gray

# ✅ or lift the cases into a union and match the variant
type Urgency = Overdue | Today | Later
match urgency with | Overdue -> red | Today -> amber | Later -> gray

1.9.2 Alternatives to Higher-Order Functions ​

kumiki
items.map($1 * 2)                          # expression fragment
items.map(formatPrice)                     # fn name
items.filter(matchFilter($1, filter))      # fn call
items.fold(0, $1 + $2.price)               # ($1: acc, $2: elem)

1.9.3 Short-Circuit Evaluation ​

& and | use short-circuit evaluation.

1.9.4 Operator Types ​

Every operator's operand and result types, which the compiler checks (E0201):

OperatorOperandsResult
+Text on either sideText — concatenation; the other side is stringified
+ - * %both numericFloat if either operand is Float, else Int
/both numericFloat, always
< > <= >=both numeric, both Text, or both TimeBool
& |both BoolBool
== !=any two values, unless they carry two different nominal identitiesBool
unary -numericthe operand's type
unary !BoolBool

/ is Float even between two Ints. It is JavaScript's / at runtime — 5 / 2 is 2.5, not 2 — so an Int result type would be a promise the runtime does not keep, and fn half(x: Int) -> Int = x / 2 is rejected. Take .to-int (truncating, stdlib §2.2.7) where a whole number is wanted, or declare the Float.

== compares by value. Kumiki values are immutable (§1.6.3), so a program has no reference identity it could mean to compare: two Lists, tuples, records, Maps or variants are equal when what they hold is equal, all the way down — xs == [] on an empty list, (0, 0) == (0, 0) and Some({x: 1}) == Some({x: 1}) are true, [1, 2] == [2, 1] is false. A record's fields and a Map's entries compare regardless of the order they were written in. != is the negation. List.contains and List.unique ask the same question (stdlib §2.2.3), and so do the test layer's comparisons. A Set compares as the keys it is stored under (stdlib §2.2.2), which depends on how the Set was built, so equality over Sets is not a rule a program may rely on.

== is total over every shape — an Int and a Text, an Option and its None — and nominal is the one exception. Two declarations over one base are two types (§1.3.5), so comparing them is the same mistake as assigning one to the other and is the same error: postId == userId is E0201, exactly as postId := userId is. Ordering refuses the pair for that reason as well as for the family one — cents < yen shares the number family and is still E0201.

The comparison is refused only when both sides carry a nominal name and neither was declared as the other, which is the assignment rule read symmetrically: a value with no identity of its own compares with any nominal over it, so cents == 0 and postId == "" stand as the assignments do, and a Deep declared nominal Cents compares with a Cents in either order. Converting is the same fn through the shared base that an assignment needs.

The identity is read at the top level of each operand only. A nominal inside a type argument, a container or a record field is invisible here, which is where the operators and assignment part company: List(Cents) := List(Yen) is E0201, and lc == ly on the same pair is not reported. That silence is a missing diagnostic rather than a wrong one, which is the reading every rule in the check keeps.

EffectId is outside this table: only == and != apply to it (E0204).


1.10 Namespaces and Reference Resolution ​

  • A flat global namespace
  • A separate namespace per layer
  • References are written by name and resolved to a content-hash when stored in the CRDT graph
  • Rename = a CRDT op that creates a different hash under the new name and updates references

→ AI Editing


1.11 content-hash Computation ​

hash(def) = blake3(
    canonical(def.body)
  ⊕ hash(direct-dependency-1)
  ⊕ hash(direct-dependency-2)
  ⊕ ...
)

1.12 Application Entry (app) ​

app-def    ::= 'app' identifier
               'caps'   '=' '[' (capability-name (',' capability-name)*)? ']'
               'routes' '=' route-map
               ('init'  '=' '[' emit-list ']')?
               ('theme' '=' identifier)?
               ('http'  '=' http-config)?
               ('meta'  '=' meta-config)?
               ('indexed-db' '=' idb-config)?
               ('analytics'  '=' analytics-config)?

route-map  ::= '{' route-entry (',' route-entry)* '}'
route-entry ::= string '->' identifier        ; to a tile name
              | string '->>' string           ; static redirect
emit-list  ::= effect-call (',' effect-call)*

→ Routing, HTTP / Storage

kumiki
app TodoApp
    caps   = [storage.read, storage.write, http.get]
    routes = {"/" -> TodoList, "/todo/:id" -> TodoDetail, "/404" -> NotFound}
    init   = [loadTodos()]
    theme  = DefaultTheme

1.12.1 When init arguments are evaluated ​

An init entry is an effect call, and its arguments are ordinary expressions — a slot reference included. They are evaluated once, when the app object is constructed, and the resulting values are never re-read: a later change to a slot is not reflected in an argument already captured.

What a slot reference sees at that moment is its declared default. route is the exception — it is maintained by the runtime and does not exist yet, so init = [load(route.path)] is a compile error (E0120), as is $route, which the runtime does not bind here either. Take the route from a route.enter reducer instead.

The restriction is on what an argument reaches, not on how it is written: init = [load(here())] where fn here() -> Text = route.path is the same error, reported at the call with the chain that gets to the route. A fn that reads the route stays legal everywhere else — a tile, a reducer and an effect's map-request all run after the mount that installs it.

now is available and is captured the same way: it evaluates to the moment the app object was built, not to the moment the effect runs. An entry that needs the time of its own dispatch should take it in the reducer that handles the result.

There is no reducer around these arguments either, so an emit expression is not available in one (E0305): the entry itself is the dispatch.


1.13 Counterexamples ​

kumiki
# ❌ local state
tile Foo = let x = 0 in button(text=x.show)   # a tile body has no `let`: a parse error here, E0128 as a child

# ❌ direct effect call
reducer r on=ui.click(B) do= http.get("/")   # emit required

# ❌ lambda
button(onClick=(() -> count + 1))            # not allowed, only a reducer name

# ❌ null
type User = {name: Text | null}              # use Option(Text)

# ❌ arbitrary predicate
type Even = Int where ($1 % 2 == 0)          # registered predicates only

# ❌ reading a slot inside a fn
fn current() = todos                          # receive it via a fn argument

# ❌ CSS attribute selector
reducer r on=ui.change(input[type=file]) do= ...   # write it by tile name

# ❌ literal match pattern
match status with | "open" -> ... | "closed" -> ...   # patterns are variant/_ only; use if/else or a union (Prohibitions)

# ❌ $1 in a tile with no in=
tile Row = card(text(issues[$1].title))               # E0103: declare `tile Row in=IssueId = …` (Positional Binding / Examples)

# ❌ a tile call inside the props block
link(to="/x") {text("Home")}                           # `{...}` is key:value props only; pass the label as text="Home" (Syntax)

1.14 Complete Example: Counter ​

kumiki
type N      = nominal Int where between(0, 999)
slot count  : N    = 0

reducer inc   on=ui.click(IncBtn)   do= if count < 999 then count := count + 1
reducer dec   on=ui.click(DecBtn)   do= if count > 0   then count := count - 1
reducer reset on=ui.click(ResetBtn) do= count := 0

tile IncBtn   = button(text="+")
tile DecBtn   = button(text="-")
tile ResetBtn = button(text="reset")

tile App = column(
             heading("Count: " + count.show),
             row(DecBtn, ResetBtn, IncBtn) {gap: "sm"})

app Counter
    caps   = []
    routes = {"/" -> App, "/404" -> App}
    init   = []

→ Standard Library, Routing, apps/01-counter