# TS Core

> Authoring guide for TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1060), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events).

- Skill: `gkurt/ts-core` (Agent Skill)
- Install (CLI): `npx skillmds@latest add gkurt/ts-core`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gkurt/ts-core/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: gkurt (https://skillmd.com/u/gkurt)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/gkurt/ts-core

---


# Author app cores in the TypeScript subset

An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. You write it as a TypeScript module rooted at `src/core.ts` - splitting into more modules under `src/` when it grows (see "Splitting a core into modules") - and the build checks the whole import graph with the `@native-sdk/core` frontend and compiles it to native code with the external core compiler. No JS engine ships in the binary — the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason. The same file is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, so you can poke behavior with plain node scripts before the native build.

A whole TS app is three files of truth and zero Zig: `src/core.ts` (this guide; plus any modules it imports under `src/`), `src/app.native` (the markup view over the core's model), and `app.zon` (windows, identity, permissions). `native init` scaffolds exactly that; the build detects `src/core.ts` in the tree (never a flag or config — a tree with both `src/core.ts` and `src/main.zig` is a teaching error) and generates the wiring outside the app. The loop:

```sh
native dev --core   # the fastest loop: run the core under node's virtual host —
                    # dispatch Msgs as JSON lines ({"kind":"add"}, {"$bytes":"…"}
                    # for bytes payloads, {"advance":1000} to run virtual timers),
                    # watch the model + effect transcript. Logic only, no renderer.
native dev          # build and run the real app (markup hot reload)
native check        # subset-check core.ts + validate markup + app.zon
native build        # ReleaseFast binary; native test runs the app's tests
```

The complete reference app in this idiom is `examples/soundboard-ts` in the SDK repo: the soundboard music library as three files and zero Zig — const catalog tables, REAL audio through the `Cmd.audioPlay` stream, scrub-to-seek on a markup slider, a motion-gated `Sub.timer` playback clock, the full text-edit engine on a search field, controlled scroll, registered cover assets, the width-adaptive grid through the frame channel, clipboard, and context menus, with an end-to-end suite driving the shipping markup.

## The contract

```ts
export interface Model { /* readonly data fields only */ }

export type Msg =
  | { readonly kind: "add" }
  | { readonly kind: "toggle"; readonly id: number };
  // ...one arm per thing that can happen; at least two arms

export function initialModel(): Model { /* pure */ }

export function update(model: Model, msg: Msg): Model {
  switch (msg.kind) {
    // one case per arm; the switch must be exhaustive (no default needed
    // once every arm is present — a missing arm is a build error)
  }
}
```

- `update` is pure and synchronous: next model out, plus optionally command data describing effects. When a dispatch needs an effect, declare the return type `Model | [Model, Cmd<Msg>]` and return `[nextModel, cmd]` — the runtime interprets the command after the model commits and dispatches any result back to you as a `Msg`. `initialModel` may return the same pair (`[Model, Cmd<Msg>]`) to run a boot effect once at install, and an app that needs recurring timers exports `subscriptions(model): Sub<Msg>`. See "Effects are Cmd data" below.
- Exported helper functions (`export function doneCount(model: Model): number`) compile to public native functions — and every exported helper taking exactly ONE Model parameter also becomes a Model declaration markup binds by the helper's own name (`{doneCount}`), so derived values need no model field. One binding name per member: a helper that collides with a field (or another helper) is a taught NS1031.
- Update-only state (fields, helpers, or Msg kinds nothing in markup binds or dispatches — host-fired timer arms, persistence bookkeeping) is declared once: `export const viewUnbound = ["nextId", "tick"] as const;`. It emits as the `view_unbound` opt-out `native check`'s unbound-state lint reads; a name outside the model surface is a taught NS1032. Entries are the TypeScript names exactly as declared (`"nextId"`) and Msg kinds as their `kind` tags — the same names markup binds, because there are no other names.
- Names: your names are your names — fields, helpers, and locals emit into Zig with their TS spellings (`doneToday` stays `doneToday`), and markup binds them verbatim. String-literal unions emit as native enums.

## The arena mental model

Everything `update` builds lives in a per-dispatch bump arena that is freed wholesale after the returned model is committed, so spreads, `map`, and `filter` are cheap by construction. At commit, only nodes your update actually created are copied into the persistent model heap — everything you spread through unchanged is shared with the previous model for free.

That is why the immutable style is not a performance tax: `{ ...model, tasks: model.tasks.map(...) }` copies one small struct and one pointer array, never the world.

Both regions are the compiled core's own: the frame arena bounds one dispatch's transients, the model heap holds the committed model between dispatches, and the compiler's determinism fences keep every dispatch allocation-shaped and replayable.

## What the subset means

The subset is TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Concretely: every basic statement, operator, and declaration form of the language compiles (every loop shape including `do...while`, labels with labeled `break`/`continue`, `switch` with `default`, the full assignment-operator family, `**` and the shifts, const record destructuring, namespace imports over your own modules). What does not compile falls into exactly two families, each with a named teaching rule: the ECOSYSTEM the binary cannot carry (npm packages, Node/DOM APIs, regex/JSON/Promise/generator machinery, `eval` — no JS engine ships), and the constructs that would break a core's guarantees (purity and determinism, fixed shapes, one text representation, functions as declarations not values, static types with no runtime tags). Classes and exceptions are NOT in either family: data classes and `throw`/`try`/`catch`/`finally` compile (see below) — only their guarantee-breaking tails (inheritance, unsafe finally, untagged thrown values) teach. A construct that fails with a generic error instead of a teaching rule is a checker bug — the grammar matrix test (`grammar_matrix.test.ts`) pins every grammar production to its verdict so no silent gap can appear.

The banned families at a glance (each diagnostic names the fix and the reason at the site):

- **No JS engine ships**: `eval`, `new Function`, dynamic `import()`, `debugger` (NS1013); regexes (NS1040); BigInt/Symbol (NS1044); generators (NS1042); async/Promises (NS1002); npm imports (NS1035).
- **Purity and determinism**: mutation of SHARED data — parameters, model/msg trees, module tables, escaped locals (NS1001/NS1022/NS1051; your own function-local scratch arrays mutate freely, see "Local mutation" below), module-level `let` (NS1010), ambient time/randomness/IO (NS1005), effects outside the Cmd/Sub return paths (NS1017/NS1025).
- **Fixed shapes and layouts**: the class machinery beyond data classes — `extends`/`super`/`abstract` (NS1055), accessors/`#`-privates/class expressions/`this`-as-a-value (NS1056/NS1006), mutable statics (NS1010), generic classes (NS1053) — plus `delete`/getters/setters/computed keys (NS1012), `for`/`in` (NS1009), Map/Set (NS1011), runtime type/shape tests — `typeof`/`in`/`instanceof`/`Object.*` (NS1041). Data classes themselves compile — `static` methods, `static readonly` consts, and erased `private`/`protected` included (see "Data classes" below).
- **One text representation**: string indexing (NS1004), `+` concatenation and tagged templates (NS1018), `string` model fields (NS1024), the byte-text stays-out spellings — `charCodeAt`/`normalize`/`replace` and friends on bytes teach the byte-honest form (NS1060; the supported method surface is under "Text is bytes").
- **Functions are declarations — or const local helpers**: nested function declarations, non-const function values, `?.()` (NS1046), and const helpers that capture/escape/under-annotate (NS1054 — the legal shape is below under "Local function values"); fixed arity — no defaults, rest, `arguments`, call spreads (NS1019); generics live on module-level declarations and monomorphize per call site (NS1050/NS1053).
- **The mapping stays exact**: `var` hoisting (NS1049), loose `==` (NS1048), comma/void/assignment-as-value outside a for-header (NS1043), array/parameter destructuring (NS1045), `export default`/`export =`/`export * from` (NS1047 — export lists and named value re-exports compile), namespace-alias-as-value and SDK namespace imports (NS1039).

## What compiles (v1)

Model and message shapes:

- `interface` with `readonly` fields; nested interfaces; `T | null` for optional data.
- Model field types: `number`, `boolean`, string-literal unions (`"all" | "active" | "done"` → native enum), numeric-literal unions, `Uint8Array` (bytes), a nested interface, `readonly Interface[]` (arrays of object types), primitive arrays (`readonly number[]`, `readonly boolean[]`, arrays of literal-union tags), a tag-discriminated union (`{ kind: "list" } | { kind: "detail"; note: Note }` — arms may carry records, bytes, and primitive arrays), and `T | null` over any of these. Model unions compile to native tagged unions; switching arms in `update` retires the old arm's payload automatically at commit.
- `Msg`: a discriminated union on a `readonly kind` string tag, with primitive / bytes / interface payload fields. It must be a real union — give it at least two arms, or TypeScript collapses the alias to a plain object type and the checker rejects it.

Logic:

- `switch` on any union's `kind` tag — `msg.kind` (the Msg dispatch) and model-field unions (`switch (model.view.kind)`) alike — with member case labels, label stacking (`case "a": case "b": body`), `break`, and a trailing `default` covering the unnamed arms (without a `default` the switch must be exhaustive — NS1015; with every arm named the `default` is JS dead code and emits nothing) — and `switch` on a string-literal-union or numeric-literal-union *value* (`switch (model.filter)`) — an uncovered member skips the switch exactly like JS (a `default` anywhere but last is a taught stop in both forms) — and `switch` on a plain `number` or `string` value, lowered to an if/else chain with exact JS semantics: strict equality per case (NaN matches nothing, `-0` matches `0`, strings compare contents), cases tested in source order, `default` matching only after every case misses wherever it sits (an empty `default:` stacking onto the next body included); `if`/`else`; classic `for (let i = 0; ...)` including countdowns (`i--`, `i -= k`), multi-counter inits (`let lo = 0, hi = n`), and comma incrementors (`lo++, hi--` — the for-header is the one home for comma sequences); `do { ... } while (cond)` (the body runs before the first test; `continue` jumps to the test, exactly node); `for (const x of xs)` over arrays and `Uint8Array`, with `break`/`continue`, plus the indexed pair form `for (const [i, x] of xs.entries())` (exactly the `[index, element]` two-identifier binding — the index is the loop index, integer-classed; other tuple shapes stay taught); `while`; labeled statements on loops and blocks with labeled `break`/`continue` (`outer: for (...) { ... continue outer; }` — a labeled `continue` in a classic for still runs the incrementor, like JS); `let` locals with reassignment (and `let x: number;` declared-then-assigned); ternaries; `&&`/`||`/`!`; the empty statement `;`.
- `const { total, done: doneCount } = stats;` — record-field destructuring into const locals (a compile-time alias per field, renames included). Array patterns, parameter patterns, defaults, rest, and nesting are taught (NS1045 — positions can be silently absent in JS; fields cannot).
- `import * as util from "./util.ts"` — a namespace import over your own modules is pure dot-syntax: `util.helper(x)`, `util.CONST`, and `util.Cfg` in type positions all resolve to the target module's flat names. The alias is not a value (storing or passing `util` itself is taught), and the intrinsic `@native-sdk/core` module is always imported by name (NS1039 — the purity rules recognize `Cmd`/`Sub`/`asciiBytes` by their imported names).
- Object spread `{ ...model, field: v }`, array spreads in any shape — append `[...xs, x]`, prepend `[x, ...xs]`, multi-spread `[...a, x, ...b]` (each compiles to one exact-size copy) — `.length`, indexing `xs[i]`.
- Array methods, lowered to inlined loops and exact-size arena copies: `.map` / `.filter` / `.find` / `.findIndex` / `.some` / `.every` / `.reduce` / `.toSorted` / `.slice` / `.concat` / `.indexOf` / `.includes`. `.map` is type-changing — `tasks.map((t) => t.id)` produces a number array, `t => t.title` a bytes array, and a callback that can return `null` produces an optional-element array. Callbacks on map/filter/find/findIndex/some/every may take the `(element, index)` pair — the index is the loop index, integer-classed (`.reduce` stays `(acc, x)`: its index parameter is not in v1, and no callback takes the third JS parameter, the array itself — reference the array by name). Array-method calls may sit directly in `if`/`else if` and ternary conditions (`if (xs.some((x) => x > 3))`) — the scan lowers to a loop just before the branch; a `while` condition cannot (it re-evaluates per iteration — hoist into the loop body or restructure). Callbacks are arrows (expression or block body), inline `function` expressions, or a BARE REFERENCE to a module-level function or const helper (`xs.map(encodeTurn)`, `xs.toSorted(byAscending)` — the referenced body inlines exactly like the arrow spelled at the site); in a block body every code path must end in an explicit `return` (falling off the end would be JS `undefined`, which has no mapping — a taught stop). JS semantics hold exactly: `.slice` resolves negative and out-of-range indices the JS way, `.indexOf` never matches `NaN` while `.includes` does, `.some`/`.every` keep their vacuous defaults on empty arrays, and `.reduce` needs its initial value (the no-initial form throws on an empty array in JS, so it is a taught NS1007 — pass the starting accumulator). `.indexOf`/`.includes` work on scalar elements (numbers, tags, booleans); on record arrays JS compares references, which has no native mapping — match a field with `.find`/`.findIndex` instead.
- **Local mutation — your own scratch is yours; shared data is immutable.** An array your function CREATES — an array literal (`const stack: number[] = []`, `const st = [1, 2, 3]`) or a fresh copy (`.slice()` / `.map()` / `.filter()` / `.concat()` / `.toSorted()`) — is locally owned, and the full mutating method set works on it with exact JS semantics: `push(...items)`, `pop()`, `shift()`, `unshift(...items)`, `splice(start, deleteCount?, ...items)` (negative/overshooting indices clamp the JS way; the value is the removed array, also yours), `reverse()`, `fill(v, start?, end?)`, in-place `sort(cmp)`, and indexed writes `xs[i] = v`. A parser stack, a work queue, a copy-then-sort — all legal, deterministic, and byte-identical to node. Ownership ends at the first ESCAPE: once the array is returned from a callback, stored into a record/array/model, aliased by a second binding (`const b = a`), or passed where the callee could keep or mutate it, mutating it afterwards is a taught NS1051 — finish mutating first, then let it escape (an early-exit `return` is fine: execution ends there, so mutations on the other path stay legal). Two loosenings keep real code flowing. BORROWING: passing an owned array into a `readonly T[]` parameter is NOT an escape when the callee only READS it (element/property access, iteration, spreads, further borrowing passes — no return of it, no store, no onward pass into a mutable position; recursion over borrowed slices included), so measure-mutate-measure loops work (`total(out); out.push(x); total(out)`). REASSIGNED-OWNING: a `let` binding whose EVERY assignment installs a fresh owning construction (a literal or a copy — `w = xs.filter(...)`, `acc = []`) stays owned through the reassignments; ONE mixed assignment (an alias, a parameter, a helper result) and the binding never owns (NS1001 names it). Never owned: parameters, model/msg data, module `const` tables, aliases, mixed reassigned bindings, and arrays produced by helper calls (copy with `.slice()` to own one). After the value escapes it is an ordinary immutable value; the commit walkers and sharing discipline are unaffected because ownership ended before the escape.
- Local-mutation shape notes: `push`/`unshift` return the new length in JS, which has no mapping — mutate as a statement and read `.length` after; `sort`/`reverse`/`fill` return the same array — mutate as a statement, then use the array by name (`return copy.sort(cmp)` is a taught stop; the canonical form is `const copy = xs.slice(); copy.sort(cmp); return copy;`); `pop()`/`shift()` return `T | undefined` — the same one-empty the `.find` miss produces, so test `=== undefined` or fold with `??` (`stack.pop() ?? fallback`); spread arguments (`out.push(...xs)`) stay taught — one element per iteration; `xs[xs.length] = v` on an owned array IS a push (the one growth shape — compound forms like `xs[xs.length] += v` read the missing slot first and stay taught), and other out-of-bounds writes are JS sparse arrays with no mapping (they trap on the native bounds check in safe builds — keep writes inside `0..length-1`); changing the LENGTH of the array a `for...of` (or one of its own callbacks) is iterating is a taught stop (JS walks the live array; fixed-length writes during iteration are fine and identical to node); `copyWithin` stays out of v1 (splice/fill cover it).
- `.toSorted(cmp)` sorts a copy in one expression; `.sort(cmp)` sorts in place on an array you own (on shared data it keeps the NS1022 teaching, which names the copy idiom). Both comparators follow the same rules: return a sign — `(a, b) => a - b` for ascending numbers, or explicit -1/0/1 branches; a boolean comparator is wrong in JS itself (false claims equality) and is rejected by the types plus a taught NS1023. The comparator-less arity sorts by string ToString order in JS (`[10, 9]` stays `[10, 9]`), which has no float-text mapping — pass a comparator. Both sorts are stable exactly like JS: comparator 0 (or NaN) keeps the original order of the pair. One honesty note: a comparator that is inconsistent over the actual data (e.g. `a - b` when elements can be `NaN`) is implementation-defined in JS itself, so node and native may then disagree — keep comparators consistent.
- A `.find` miss is the tier's one empty value: JS spells it `undefined`, so test the result with `=== undefined` (never `=== null` — the checker teaches the difference) or fold it away with `??`: `tasks.find((t) => t.id === id) ?? fallback`.
- Optional chaining `?.` on property chains (`model.sel?.at ?? 0`), element hops (`m?.xs[0] ?? 0`, `xs?.[i] ?? d`), and method hops on supported receivers (`xs?.slice(0, 2)`, `xs?.includes(3) ?? false` — every mapped array/bytes method): each hop null-propagates exactly like JS, and the chain value is optional — end it in `??` or compare it against a real value. A `?.` chain compared against `null`/`undefined` is a taught error (NS1021), and `g?.()` on a function value stays taught.
- Null-guard narrowing through `&&`/`||` chains, exactly the way TS narrows: `if (x !== null && x.items.length > 0)`, the flipped order (`null !== x`), the `||` dual (`x === null || x.items.length === 0`, including as an early-exit guard — the code after the exit stays narrowed), ternary conditions (`x !== null && x.at > 0 ? x.at : -1`), and `while (cur !== null && cur.n > 0)` loops (re-tested per iteration; assigning the guarded local drops the narrowing for what follows, like TS). Relational comparisons on guarded optionals (`cls !== null && cls < lim`) work too.
- Nullish `??`, comparisons (including `===` on `string`-typed values — content equality, same as node; `==`/`!=` are taught NS1048 — coercion), `+ - * / % **` on numbers, unary `+`/`-`, and the bitwise family `& | ^ ~ << >> >>>` — all with JS number semantics (`/` is float division, `%` truncates, `**` is float pow with the exact JS corners — `1 ** NaN` is NaN, `(-1) ** Infinity` is NaN, right-associative `2 ** 3 ** 2` is 512; bitwise and shifts are ToInt32 with the shift count masked & 31, `>>>` yielding the unsigned 32-bit value; unary `+` is the identity on numbers). `**` and `/` results are float-classed; bitwise/shift operands are integer-required positions (a float operand is a taught NS1016).
- Every compound assignment as a statement: `+= -= *= /= %= **= &= |= ^= <<= >>= >>>=`, each exactly `x = x op v`, plus the guarded forms `&&=`/`||=` (boolean targets; the right side evaluates only when assigned, like JS) and `??=` (optional targets; assigns only when null). A number `++`/`--`/assignment may sit in a VALUE position when the split statement is provably order-exact — the variable's only mention in the statement, in a position JS cannot skip (`arr[i++]`, `const n = ++count`, `const z = (y = 5)`; postfix yields the pre-step value, everything else the post-step value, exactly JS); every other value-position form is taught (NS1043 — ternary branches, short-circuit right operands, loop conditions, or a second mention of the variable).
- The Math batch, every corner pinned to node: `Math.min` / `Math.max` (any arity — `Math.min()` is Infinity, `Math.max()` is -Infinity, NaN propagates, -0 orders below +0), `Math.round` (half toward +Infinity), `Math.floor` / `Math.ceil` / `Math.trunc` (NaN/Infinity propagate; the -0 results keep their sign, so `Math.ceil(-0.5)` is -0), `Math.abs` (clears the zero sign), `Math.sign` (NaN stays NaN, a zero keeps its sign), `Math.sqrt` (negative input is NaN, `sqrt(-0)` is -0). `Number.isInteger` / `Number.isFinite` / `Number.isNaN` classify like node, and the `NaN` / `Infinity` globals are ordinary number values. Math calls over compile-time constants fold to their exact JS value — `const HALF = Math.floor(5 / 2)` is the integer 2, `5 % 0` is NaN, `-5 % 5` is -0. A bare `-0` literal (and constant arithmetic folding to -0, like `0 * -1`) is a float value — only f64 carries the signed zero — so it cannot flow into an index or another integer-required slot. The integer rule: floor/ceil/trunc/abs/sign of an integer-classed value stays integer-classed, but of a float value stays float (floor of NaN is NaN), so `bytes[Math.floor(x / 2)]` over a float `x` is still a taught NS1016 — keep index flows integer end to end.
- Template literals with integer holes (`` `${n} of ${total}` ``) feeding `asciiBytes` (below). Float-valued holes are not in v1 (JS float-to-string fidelity is a runtime v2 surface).
- Module-level `const` numbers and strings fold to comptime constants, and const tables emit as rodata (no arena, shared for free at commit): arrays of numbers / booleans / strings / literal-union members (`const WEEKDAYS = [3, 5, 2]`, `const ORDER: readonly Filter[] = ["done", "all"]`), records annotated with an interface (`const LIMITS: Limits = { lo: 1, hi: 9 }`), and arrays of records (`const SEEDS: readonly Task[] = [...]`, names as `asciiBytes` literals). Element access, `.length`, `for...of`, and the array methods all work over tables. Everything inside must fold at compile time — no spreads, no calls except `asciiBytes` on a literal — and a record table needs its interface annotation (an unannotated `{ ... }` is a taught stop naming the fix). Helper functions; recursion.
- **Generics — module-level, monomorphized per call site.** A generic `function`, `interface`, or `type` declares type parameters and instantiates from tsc's RESOLVED type arguments (explicit or inferred): `export function pick<T>(xs: readonly T[], i: number): T { return xs[i]; }` called with tasks emits `pick__Task`, with numbers `pick__f64` (a bare `number` type argument is always f64 — the JS-exact class), one readable Zig fn per distinct instantiation, deduped. Generics over records, unions, arrays, optionals, and bytes all work; generic interfaces/aliases instantiate structurally (`Box<Task>` emits `Box__Task`; `type Opt<T> = T | null` resolves straight through); generics may recurse and call other generics (the inner call resolves at the outer's instantiation). `typeof CONST` type-query aliases resolve through tsc too (`const LIMIT = 9; type Limit = typeof LIMIT`). The boundaries teach: a call site whose type argument stays abstract (`pick([])` infers `never`; `any`/`unknown`, unnamed literal unions) is NS1053 — annotate the call or name the alias; generic function VALUES and generic entry points are NS1050.
- **Data classes — fields, one constructor, plain methods, statics; no inheritance.** `class Task { title: Uint8Array; done: boolean = false; constructor(title: Uint8Array) { this.title = title; } toggle(): void { this.done = !this.done; } isDone(): boolean { return this.done; } }` emits as a plain struct plus module-level functions; `new Task(...)` constructs a record-shaped value (field initializers run in declaration order, then the constructor body). `static` members are per-class module declarations: a `static` method lowers to a receiver-less module fn under the class's mangled name (`Task.fromRow(...)` resolves to `Task__fromRow`), and a `static readonly` field with an initializer is a module const (`Task.LIMIT` — the module-const value rules apply: numbers/strings fold, tables need their annotation); a MUTABLE static is module state and teaches NS1010, and inside a static member reach other statics by the class name, never `this` (NS1056). `private`/`protected` keywords are accepted and ERASED — tsc enforces them at the type level, which is their whole meaning (`#`-fields stay taught: runtime privacy brands). `this` reaches instance fields and methods (`this.count`, `this.step()`) — anything that lets `this` escape as a value (returning it, storing it, passing it) is taught (NS1056), so fluent chaining is out. Mutation follows exactly the array ownership rule: an instance your function creates with `new` mutates freely — direct field writes (`t.count = 1`, `t.count += 2`) and methods that write `this` — until it ESCAPES (returned, passed, stored, aliased — then NS1051), and parameters/model data never mutate (NS1001); methods that only read are callable on anything. Fields require type annotations; instances flow between functions, sit in arrays, and compare/narrow like records. The class TAIL teaches by name: `extends`/`super`/`abstract` (NS1055 — compose, or model variants as a `kind`-union), getters/setters/`#`-privates/`accessor`/class expressions (NS1056), mutable statics (NS1010), generic classes (NS1053), parameter properties (NS1008), `instanceof` (NS1041 — a `kind` field is the tag that exists). Class instances stay LOCAL values in v1: storing one in the Model tree is taught (NS1056) — keep records (interfaces) in the Model and construct the class where behavior is needed.
- **Exceptions — `throw`/`try`/`catch`/`finally` as pure control flow.** Inside a core, exceptions are deterministic: `throw` carries a subset VALUE and unwinds to the nearest enclosing `catch` — across helper calls, out of array-method callbacks (a `throw` inside `.map`'s callback exits the whole loop, like JS), through nested `try`s, with `finally` running on every path (fall-through, `return`, `break`/`continue`, and throw alike). The discipline is two rules. First (NS1057): thrown values are kind-tagged subset shapes — throw kind-discriminated records (`throw { kind: "bad_digit", at: i } as ParseError`, where `ParseError` is an interface with a string-literal `kind` field or a `kind`-discriminated union; a single-shape core may also throw a number), and SEVERAL distinct shapes may throw: the checker collects every shape the core throws into its implicit thrown union. The catch binding IS that union — narrow it in place with kind tests, no `as` ceremony: `catch (e) { if (e.kind === "bad_digit") return -e.at; if (e.kind === "io") return e.code; return -1; }` (or `switch (e.kind)` — tsc cannot prove exhaustiveness over the implicit union, so give the switch a `default` or a trailing return). Bare rethrow (`throw e;`) re-raises the bound value — a narrowed arm included — and `catch { ... }` needs no binding; the single-`as` form (`const err = e as ParseError;`) stays legal in single-shape cores (and for a DECLARED union whose arms equal the thrown set — declare `type AppError = ... | ...` and `as AppError` works). What teaches: untagged values in a heterogeneous set, two shapes sharing one `kind` with different payloads, asserting one member shape of a multi-shape core, the binding escaping untyped into a call/store/return, and `throw new Error(...)` (engine error objects carry stack traces with no native layout). Second (NS1058): `finally` never redirects control flow — no `return`/`throw`/`break`-out inside it (JS's own no-unsafe-finally rule; loops fully inside the finally may break within themselves). An UNCAUGHT throw that reaches an exported function's boundary is a defined deterministic panic — exactly where node's process would crash. A throw mid-mutation of an owned array keeps the mutations applied so far, exactly like JS — the catch sees the array as node would.
- **Local function values — const helpers hoist.** `const scale = (x: number): number => x * 3;` (arrow or `function` expression) hoists to an ordinary module-level fn when it is capture-free (module constants and other const helpers are fine to reference; enclosing locals/params are not — pass them as parameters), fully annotated (every parameter and the return type), and used only by direct calls (`scale(v)`, recursion included) or as an array-method callback (`xs.map(scale)`, comparators included). Everything else teaches NS1054: captures, missing annotations, `let` bindings, returning/storing the value, passing it to your own functions, calling through a record field. Capturing a locally-owned array also ENDS its ownership at the capture (a later mutation is the NS1051 teach) — the stored closure would retain the reference.

Not yet in v1 — genuine roadmap deferrals, each stopping with a loud, tailored NS9001 naming the rewrite (never missing basic syntax; the banned-with-a-rule families live in "What the subset means" above): `.toSorted()`/`.sort()` without a comparator (JS ToString ordering; pass `(a, b) => a - b`), `.reduce` without an initial value (a taught NS1007 — JS throws on an empty array) or with an index parameter (use a classic loop), `.indexOf`/`.includes` on record arrays (match a field with `.find`/`.findIndex`), `.join` on number arrays (elements are float-valued; join byte values instead), float values (`/`, `**`, `Math.round`, `Math.sqrt`, float `Math.floor`-family results) where an integer is required such as an index (a taught NS1016 — those values can be fractional or NaN), Math methods beyond the batch above, `Number` methods beyond the three classifiers, float-valued template holes (JS float-to-string fidelity is a runtime v2 surface), arrays of unions (`readonly View[]`) or arrays of byte-strings (`readonly Uint8Array[]`) as model fields (wrap the element in a single-field interface), record payloads on `Cmd.request` results (results and errors arrive as one bytes payload; the record-shaped results are `Cmd.fetch`'s `{ status, body }` arm, `Cmd.spawn`'s collect `{ code, output }` arm, and the fixed audio event arm), streaming fetch responses (`Cmd.fetch` is buffered only; spawn line streams are the streaming surface), a collect spawn's stderr tail (v1 delivers the exit code and stdout; stderr is not surfaced — put diagnostics on stdout or check the code), per-line truncation flags (a stdout line over the engine's 4 KiB line bound arrives cut, without a flag), and non-timer subscriptions (`Sub.timer` is the one subscription; one-shot needs are `Cmd.delay`, and process/audio streams are Cmd-initiated, not subscribed).

## Effects are Cmd data

`update` never performs an effect — it can return one, as inert data, alongside the next model. Import the factories from the SDK and declare the pair-return type:

```ts
import { Cmd } from "@native-sdk/core";

export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
  switch (msg.kind) {
    case "add":
      return [{ ...model, count: model.count + 1 }, Cmd.persist()];
    case "request_time":
      return [model, Cmd.now("tick")]; // dispatches { kind: "tick", at: <ms> }
    case "tick":
      return { ...model, lastTick: msg.at }; // bare model = [model, Cmd.none]
    case "ship":
      return [model, Cmd.batch([Cmd.persist(), Cmd.host("beep", model.count)])];
  }
}
```

The command set (Cmd wire format v3):

- `Cmd.none` — no effects; returning a bare `Model` is sugar for `[model, Cmd.none]`.
- `Cmd.persist()` — ask the host to persist the committed model.
- `Cmd.now("tick")` — request a timestamp; the runtime dispatches the named Msg arm with the time (ms) as its payload. The target arm must carry exactly one number field (`{ kind: "tick", at: number }`), and tsc checks that for you.
- `Cmd.host(name, ...args)` — a fire-and-forget host command by literal name; the host decides what the name means. Args are numbers, OR exactly one bytes payload: a `Uint8Array` (`Cmd.host("clipboard.write", model.draft)`) or a flat inline record of number/boolean/`Uint8Array` fields (`Cmd.host("cfg.save", { gain: model.gain, on: model.muted, label: asciiBytes("main") })`) — the record lowers to one bytes payload from your types at build time, byte-identical under node and native. Anything else (a smuggled string, a nested record, a payload plus extra args) is a taught error (NS1020/NS1026).
- `Cmd.request(name, payload, { key?, ok, err })` — a routed host command: the host performs `name` with the payload (same bytes/record rules) and dispatches exactly one result back to you as an ordinary Msg — the `ok` arm with the result bytes on success, or the `err` arm with the error bytes on failure. Both arms must carry exactly one `Uint8Array` field (`{ kind: "loaded", body: Uint8Array }`), checked by tsc and taught by NS1027. The routing is data — string-literal arm names, never callbacks — so the result decoder derives from your Msg types at build time. The optional `key` (a string literal) names the in-flight effect: issuing a request whose key is already in flight replaces it (the old result is dropped), which is the debounce/exactly-one-in-flight discipline.
- `Cmd.cancel(key)` — drop the in-flight keyed effect with that key, silently: a cancelled request, named engine op (`readFile`/`writeFile`/`fetch`/`clipboardRead`), or armed delay dispatches NEITHER arm — its result is simply dropped. The one exception is a live spawn stream (below): cancel ends the child and the stream's `err` arm dispatches with `cancelled` — killing a process is an observable event, kept loud on purpose.
- `Cmd.batch([a, b])` — several commands from one dispatch, performed in order.

### The named engine ops

These map directly onto the host's effect engine — files, HTTP, the clipboard, one-shot timers. Routing follows the `Cmd.request` rules (inline `{ key?, ok, err }`, string-literal arm names, arm shapes checked by tsc and taught by NS1027), with one difference from `request`: each op's `ok` arm has the op's OWN result shape. Keys follow the one keyed-effect rule everywhere: issuing an op whose key is already in flight REPLACES the old one (the superseded op's result is dropped — no message), and `Cmd.cancel(key)` drops it silently. Every `err` arm carries exactly one `Uint8Array` field and receives a machine-readable reason. Paths, URLs, and bodies are bytes (`asciiBytes` for literals); dynamic values the engine refuses at runtime surface through the `err` arm, while compile-time-knowable bound violations stop the build (NS1030).

- `Cmd.readFile(path, { key?, ok, err })` — read a whole file. `ok` arm: one `Uint8Array` field with the content. `err` reasons: `not_found`, `io_failed`, `truncated` (the file exceeds the engine's 1 MiB read bound — a cut file never passes as whole), `rejected`. Paths are at most 1024 bytes.
- `Cmd.writeFile(path, bytes, { key?, ok, err })` — write a whole file (parent directories created, an existing file replaced whole; at most 1 MiB). `ok` arm: NO payload fields (`{ kind: "wrote" }`) — a successful write has nothing to report. `err` reasons: `io_failed`, `rejected`.
- `Cmd.fetch({ url, method?, headers?, body?, timeoutMs? }, { key?, ok, err })` — a buffered HTTP(S) exchange. `ok` arm: exactly two fields, one `number` and one `Uint8Array` (`{ kind: "fetched", status: number, body: Uint8Array }`) — matched by type, so the names are yours. The status is the real HTTP status: a 404 is still `ok` (an HTTP-level error is a delivered response). `err` reasons: `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `rejected`, and `truncated` (the body exceeded the engine's 256 KiB buffered bound — never delivered silently cut). The spec is an inline object: `url` bytes (≤ 2 KiB), `method` one of `"GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"` (default GET), `headers` an inline flat record — names are compile-time ASCII, values are string literals OR runtime bytes (`{ authorization: bearerToken(model.apiKey), "content-type": "application/json" }` — how a launch-supplied key rides an `Authorization` header; ≤ 8 headers, ≤ 1 KiB total, NS1029/NS1030), `body` bytes (≤ 64 KiB), `timeoutMs` a positive integer literal (engine default when omitted). Buffered only — no streaming responses in v1.
- `Cmd.clipboardWrite(bytes)` — put bytes on the system clipboard, fire-and-forget: there is no routing, and a refused or over-bound write is dropped by design.
- `Cmd.clipboardRead({ key?, ok, err })` — read the clipboard. `ok` arm: one `Uint8Array` field with the text. `err` reasons: `failed` (no clipboard service, over-bound content, pasteboard error), `rejected`.
- `Cmd.delay(key, ms, "fired")` — a keyed ONE-SHOT timer: dispatches the named arm once, `ms` from now, with the fire time (ms) as its single number payload (the same arm shape `Cmd.now` and `Sub.timer` target). Re-issuing a live delay key re-arms it from now — that is the debounce discipline (`Cmd.delay("autosave", 800, "save_now")` on every keystroke, one fire after the pause). `Cmd.cancel(key)` drops it silently. The interval is 1ms to one year; a literal outside that stops the build (NS1030).

One honesty note on `Cmd.persist()`: it compiles and encodes, but no shipping host implements the persist verb yet, so the checker teaches NS1028 as a WARNING (never failing the build). Persist real state with `Cmd.writeFile` and load it back with `Cmd.readFile` from `initialModel`'s boot command — the pattern every real app uses.

### The streaming ops: spawn, audio, and channels

Three effect families deliver MANY results from one command — a keyed stream the app opens imperatively and drives (this is the opposite of `Sub`: a Sub is declared from the model and the host reconciles it; a stream is a `Cmd` with a lifecycle you cancel or stop). Routing still follows the `Cmd.request` rules: string-literal arm names, shapes checked by tsc and taught by NS1027.

- `Cmd.spawn(argv, { key?, stdin?, line?, exit, err })` — run a subprocess, streaming stdout line by line. `argv` is an inline array literal of bytes elements (`[asciiBytes("/bin/ps"), asciiBytes("-axo")]

…(truncated)
