01 · The core promise

What stops an agent from silently breaking my code?

A whole class of change that quietly compiles in TypeScript (a missing case, an unhandled error, a renamed field) becomes a compile error the agent has to fix before it can ship.

Why it matters

Agents don't usually break code loudly. They break it confidently: they add a state, drop a branch, or rename a field, everything still type-checks, and the bug surfaces in production. The expensive failures are the ones that compiled and were wrong.

Glyph closes that gap by turning “you probably handled this” into “the compiler proved you did.” Tagged unions are sealed, so match must cover every case. Errors are values you have to unwrap, not exceptions that vanish. And every type carries a runtime descriptor, so data crossing a boundary is checked against its shape.

See it

Add a state to a union, and every place that handled the old set stops compiling, pointing at the exact spot to update:

status.glyph: one new variant, and the compiler finds every gap
type Status = Loading | Ready | Failed(string)   // + Cancelled

fn label(s: Status) -> string {
  return match s {
    Loading => "…",
    Ready => "done",
    Failed(msg) => msg,
    // no arm for Cancelled ⇒ this match no longer compiles
  }
}
glyph build
[E0200] non-exhaustive match on `Status`: missing variant `Cancelled`
   ╰─ add an arm for `Cancelled`, or an `else` arm to catch the rest

The equivalent TypeScript switch compiles clean and returns undefined at runtime. Same for errors: a function that returns Result<T, E> is meant to be handled with match or propagated with ? (and the compiler checks the error type lines up). And if you drop a Result on the floor (call it as a statement and ignore what it returned), Glyph warns you (E0217), because a discarded error is a swallowed failure. You silence it deliberately with let _ = ..., never by accident.

The bar is the same for a union the standard library owns. fs.ErrorKind is a closed set of six kinds, and the checker knows its shape, so match e.kind on a filesystem error needs no else arm and fails with E0200 when you leave a kind out. e.mesage is E0210 rather than something tsc catches downstream. Return types travel too: string.split(text, ",") is an Array<string>, so for i, part in string.split(text, ",") binds i as a number instead of the string key you'd get from an untyped iterand.

Exhaustiveness isn't just for unions. A match on a number or string with only literal arms can't cover an unbounded domain, so Glyph requires an else (E0218) rather than letting the uncovered value fall through to a runtime throw. The rule is the same everywhere: a match that doesn't account for a case doesn't compile.

A typo can't quietly paper over a gap either. A capitalized arm head is a reference to a variant, not a fresh binding, so if you misspell Loading as Loadign, older Glyph read the misspelling as a catch-all that bound every value and made the match look total. Now the head has to name a real variant of the union you're matching. When it doesn't, the build stops with E0220 and points at the nearest real name, and the variant you actually skipped still shows up as E0200 right next to it.

status.glyph: a misspelled arm is a gap, not a catch-all
fn label(s: Status) -> string {
  return match s {
    Loadign => "…",        // typo for Loading, read as a variant, not a binding
    Ready => "done",
    Failed(msg) => msg,
  }
}
glyph build
[E0220] `Loadign` is not a variant of `Status`; did you mean `Loading`?
   ╰─ a lowercase name would be a binding; a PascalCase name is read as a variant
[E0200] non-exhaustive match on `Status`: missing variant `Loading`

That reading holds one level down, where the variant is the payload of another one. Err(Blank) beside Err(e) reads as "ignore a blank line, print every other parse error", and until 0.1.93 it compiled to two case "Err": labels on the outer tag whose first bound the whole payload under the name Blank. Every error took the first arm, the second was dead code, and nothing said so: Glyph reported no diagnostics and tsc --strict accepts a duplicate case label. The arm now dispatches on the payload's own tag, whatever the outer variant is, and the compiler reads the name the way the typechecker does: the payload union's own variant list decides, and only when that list is out of reach does the name's shape decide. For a union declared in the same file the capitalization no longer matters. For one imported from another module the variant list is out of reach today, so a capitalized variant dispatches and a lowercase one stops the build.

It stops because the switch is guarded now. Two arms that would write the same case label are E0305, not a switch whose second label can never run. That is what makes the class loud instead of silent: a lowering that reaches for the same tag twice fails the build rather than picking the wrong arm at run time.

repl.glyph: the inner arm tests the payload, it doesn't bind it
fn step(z: Zipper, line: string) -> Zipper {
  return match parse_command(line) {
    Err(Blank) => z,                          // a comment or an empty line
    Err(e) => { io.println(explain(e)) z },   // every other parse error
    Ok(command) => execute(z, command),
  }
}

How the union got into scope doesn't change any of this. Glyph lets you write a variant arm two ways, and both are checked the same: import model { Cond, Yes, No } with bare arms, or import model (or import model as m) with model.Yes(_). That holds for the standard library's unions too, so match o { option.Some(s) => … } on an option.Option<string> is E0200 for the missing None exactly as the bare Some/None spelling is. Until 0.1.56 the qualified spelling skipped the check entirely and the build reported no diagnostics on a match that threw at run time.

A string enum crosses the boundary too. type ColType = "text" | "int" | "real" | "bool" declared in catalog.glyph and imported into another module keeps its four values, so a match covering all four compiles with no else, and dropping one is E0200 naming the value you dropped. Until 0.1.57 it was E0218, with help text telling you to add an else arm. That help was wrong about the code in front of it, and taking it turned a compile error into a runtime fallthrough. All three import spellings work: import catalog { ColType }, import catalog with catalog.ColType, and import catalog as c with c.ColType.

interp.glyph: the import list came out, the match is still checked
import model { Action, All, Any, AtLeast, Clear, Condition, Context, Counter,
               Equals, Flag, FlagSet, Increment, Not, Set, Slot, Text }
import model
import std/result

pub fn evaluate(ctx: model.Context, c: model.Condition) -> result.Result<bool, SlotError> {
  return match c {
    model.Equals({ key, value }) => result.Ok(read_text(ctx, key, "equals")? == value),
    model.AtLeast({ key, least }) => result.Ok(read_count(ctx, key, "at-least")? >= least),
    model.FlagSet({ key }) => result.Ok(read_flag(ctx, key, "flag-set")?),
    model.All({ of }) => all_of(ctx, of),
    model.Any({ of }) => any_of(ctx, of),
    model.Not({ of }) => result.Ok(!(evaluate(ctx, of)?)),
  }
}
glyph build, after deleting the last arm
[E0200] non-exhaustive match on `Condition`: missing variants Not

Records cross now too, and until 0.1.58 they did not. A type declared in one file and used in another arrived with nothing known about it, which cost two separate checks. A misspelled field on it drew no Glyph error at all, so sheet.rowz fell through to tsc and came back as a TypeScript message about a shape you never wrote. And for i, r in sheet.rows could not tell that rows was an array, so it emitted Object.entries(sheet.rows) rather than sheet.rows.entries(). That binds i to the string "0". i + 1 then concatenates instead of adding, and a row that should be reported as line 2 reports as line 11.

The part worth knowing is which half tsc catches. Arithmetic on that index it does catch, though it reports the error against generated TypeScript for a variable Glyph bound on your behalf. What it cannot catch is every place a string is accepted where a number was meant: string interpolation, concatenation, a record.get key. Those compile clean under both checkers and print the wrong number. That is the shape of bug this project exists to remove, so a module boundary that reintroduces it is not a boundary Glyph can leave alone.

An imported type now carries an identity across the boundary, keyed on the module that declares it and the name it declares, so all three import spellings resolve to the same type. The declaration itself is fetched on demand and lowered on the declaring module's side, which is the half a consumer cannot do: resolution runs over spans, and an imported declaration's spans belong to another file. A record therefore keeps its field set, and a bad field is E0210 naming the record rather than a tsc error about an anonymous shape.

The query engine in examples/apps/csvql is where this surfaced. It carried three let hoists whose only job was to restate an imported field's type for the emitter, under a four-line comment explaining that without them the row and column numbers in an error message come out wrong. All three are deleted and the app prints byte-identical output for all twelve queries.

table.glyph: the hoists and the comment explaining them are gone
// Both annotations are load-bearing. `for i, x in <record field>` picks its
// lowering from the iterand's type as the checker knows it, and a field
// access is not enough: without the `let`, `i` and `j` come out as strings
// and the row/column numbers in a `BadCell` are wrong.
let raw_rows: Array<Array<string>> = sheet.rows
for i, raw_row in raw_rows {
for i, raw_row in sheet.rows {
  let typed: Array<Value> = []
  let cols: Array<ColumnSpec> = spec.columns
  for j, col in cols {
  for j, col in spec.columns {
    …
  }
}

// sheet: Sheet, declared in catalog.glyph and imported here.
// Emitted TypeScript: sheet.rows.entries(), not Object.entries(sheet.rows).
glyph build, on a typo'd field of an imported record
[E0210] type `Sheet` has no field `rowz`
// Before 0.1.58: no Glyph diagnostic at all, only a tsc error downstream.

Two things still stop at the boundary, and it is worth knowing which. Passing a catalog.Sheet where a table.Row is expected is a tsc error rather than a Glyph one, because the cross-file assignability rule waits on an unsettled question about whether type identity across a module edge should be nominal or structural. An interface's member list does not cross either. Giving it the treatment a record's field set gets would quietly change what structural satisfaction means, which is a language decision rather than a fix.

That import list is where this came from. It was written by hand in every module of a statechart engine, with a comment above it saying a union's constructors don't arrive with its type. The comment was right about the syntax and wrong about the reason: the list was there because the shorter spelling turned the check off, and nobody had noticed, because turning it off is silent. Which form you pick is now a real choice rather than a trap. The named one puts every variant you handle in the import list, which is what a grep for Equals finds. The qualified one says at each arm which module the variant came from. Neither costs you the check.

The same "make it type-level, not convention" instinct covers PII. Put @redact fields: [ssn, dob] on a type and the compiler generates a redact() on its descriptor that masks those fields, so json.stringify(User.redact(u)) can't leak them. Name a field the type doesn't have and it's a compile error (E0219), not a redaction that silently does nothing.

A descriptor is only worth anything if it's the check the type declares, all the way down. A where refinement runs its predicate wherever the type appears, not just at a direct Instant.parse: as a record field, as an Array element, as an Option payload, inside a union variant, and through json.parse<T>. The same goes for a record imported from another module. Until 0.1.41 both of those fell back to checking that the key was present, which is not a check of anything.

The scheduling app below is where this surfaced. It reads calendars from a JSON file and needs every timestamp to be a real ISO-8601 instant. Before, the refinement stopped at the top level, so a hand-edited file with "start": "no" parsed fine, the instant read as 0, and the app printed a confident schedule and exited 0. The fix is four levels down from the boundary: Calendar to Array<WireParticipant> to busy to start to the predicate.

schedule.glyph: the predicate follows the type into the field
fn is_instant(value: string) -> bool {
  return match time.parse_iso(value) {
    Some(_) => true,
    None => false,
  }
}

type Instant = string where is_instant(value)

type WireBlock = { start: Instant, end: Instant }
type WireParticipant = { name: string, busy: Array<WireBlock> }
type Calendar = { participants: Array<WireParticipant> }

// json.parse<Calendar>(text) on a file holding "start": "no"
//   => Err. It returned Ok before 0.1.41.

Tests live next to the code and run as part of compiling it. An @example above a function, or a ```glyph @run``` block inside a @doc, is executed by glyph build with no flag to remember. A false one fails the build the same way a type error does, which is the point: an agent rewriting a function body cannot pass the build by leaving the assertion behind. --no-test opts out and prints how many tests it skipped, so the bypass is visible in the log.

The JSON output agents read reports the same verdict. glyph build --json carries an examples object next to the diagnostics, and a failing example makes ok false and the exit code non-zero. Until 0.1.43 the examples ran only under --test, and the JSON path returned before they ran at all, so the one channel an agent can read printed "ok": true on a project whose own assertion was false. If tsx isn't on PATH the build fails instead of reporting a pass it never checked.

The bracket app below carries 23 of these. seed_order builds standard bracket order by doubling, which is the kind of function that is easy to get subtly wrong and easy to describe exactly. Change the doubling rule and the build stops, without a test file, a runner, or a flag.

bracket.glyph: break the seeding and the build stops
@example seed_order(2) == [1, 2]
@example seed_order(4) == [1, 4, 2, 3]
@example seed_order(8) == [1, 8, 4, 5, 2, 7, 3, 6]
fn seed_order(size: int) -> Array<int> { … }

// glyph build src --out dist
example failed: bracket example #16:
  (seed_order(8)) != ([1, 2, 3, 4, 5, 6, 7, 8])
1 of 23 example(s) failed.            # exit 1, no "tsc passed" line

// glyph build src --out dist --json
{ "ok": false, "errors": 1, "tsc": "passed",
  "examples": { "total": 23, "ran": true, "skipped": false,
    "failures": ["bracket example #16: …"] } }

Four more, found by building with it

Each of these compiled clean and passed tsc --strict, and each was found by writing an application rather than by reading the compiler. They are the same failure in three places: the types said one thing and the program did another.

reading a key that may not be there: now E0224
fn name_of(row: Record<string, string>) -> string {
  return row.naem          // a typo. compiled, and returned undefined
}

A map's keys are arbitrary, so the compiler cannot know the key is there. Typing the read as string stated something it had not checked, and the value printed as the text "undefined". It is now a compile error pointing at record.get, which returns Option<V> so the absent case has somewhere to go. Writing a key is untouched, and so is array indexing: a bound is a value a program can check, a map key is not.

a test that passed while the code failed
@example wrap("a") == wrap("a")   // passed: deep equality
fn same() -> bool {
  return wrap("a") == wrap("a")   // false. no diagnostic
}

== was documented as value equality and lowered to ===, which is reference equality the moment either side is a record, a tagged union, or an array. The same expression compared structurally in a test and by identity in the program beside it, so a test could report success on code that did not work. == is now value equality everywhere; primitives still compile to ===, so ordinary comparisons are unchanged.

exhaustiveness that evaporated inside a loop
for t in tiers {
  match t {
    "free" => ...          // "pro" unhandled: once merely a warning to add `else`
  }
}

The loop binding carried no element type, so a match over a string-literal union degraded to a match over string and the compiler advised adding a catch-all. It now names the variant you have not handled, which is advice to satisfy the check rather than to switch it off.

a variant left out of a match on an imported generic union
import tree { Tree, Node }

fn label(t: Tree<string>) -> string {
  return match t {
    Node({ key: k }) => k    // Leaf unhandled: built clean, threw at run time
  }
}

A union your own module declares is checked whether or not it takes type parameters. One imported from a sibling module was checked only when it took none. Tree<string> reaches the cross-module coverage path as an application of an imported union rather than as one, and the gate that starts the check was looking for the second, so the match was never counted at all: tsc --strict passed and a Leaf threw. Delete <K> from both files and the same program was E0200. It is E0200 either way now, at both import spellings and at an open parameter or a concrete instantiation alike.

a parse that reported success without checking: now E0304
type Conn = { id: number, sock: Socket }

Conn.parse(body)   // Ok. `sock` was never checked, and the message said it was

Every record type carries a runtime descriptor. For a field whose type the compiler cannot see into, a socket or anything reached through extern_ts, the generated check was "is it present" while the reported message read field `sock` must be Socket. A boolean that is always true is useless. One that is always true under a message naming a type nothing checked is worse, because parse is the thing a boundary is told to trust.

Holding a socket in a record is ordinary and still compiles. Validating one is now refused where it is written, naming the field and its type, and the refusal follows the shape: through a nested record, an Array, an Option. The fix it asks for is the split you would have written anyway, a wire type whose fields are all checkable, then a domain record you build from it. unknown is not caught by this, because it claims nothing and presence really is the whole check.

A signature change, invisible at the call site: closed in 0.1.116

A parameter typed string, number or bool used to accept a value of a declared union or record with no complaint at all, even though the identical call with a mismatched bool argument was already E0211. Change a function to take a payload instead of a raw string and every call still passing the old shape built clean.

billing.glyph: a union crosses a `string` parameter unnoticed
type PaymentResult = Settled(string) | Declined(string)

fn takes_string(s: string) -> number { return s.length }

fn charge(r: PaymentResult) -> number {
  return takes_string(r)   // compiled clean before 0.1.116
}
glyph build, now
[E0211] argument type mismatch: expected `string`, found `PaymentResult`
   ╰─ Pass a value of the expected type, or change the parameter's type.

The check reads the declaration, so it only fires where the answer is certain. A primitive alias, a string-literal union (D30), an extern_ts or typeof body, and an interface all still say nothing, because none of those is provably not a string the way a tagged union or record is; void is excluded too. It reaches an argument, a return, and a let annotation alike, since all three share one relation.

A const had the matching gap on the receiving side. A module-level const carrying a type annotation lowered to Ty::Unknown no matter what the annotation said, so a field typo on it was invisible exactly where the identical annotated let inside a function already reported it.

catalog.glyph: an annotated const, and the field it does not have
type Sheet = { rows: number, cols: number }
const ORIGIN: Sheet = { rows: 0, cols: 0 }

fn f() -> number {
  return ORIGIN.rowz   // compiled clean before 0.1.116
}
glyph build, now
[E0210] type `Sheet` has no field `rowz`

An unannotated const claims nothing and stays Unknown on purpose: inferring one from the initializer is a different question with a different false-positive surface. Both fixes are breaking under glyph check --no-tsc, the language server and the playground, since each starts catching a program the previous release accepted; a full glyph build was already red through tsc in both cases.

Where it stands

Shipping today

Exhaustive match over sealed unions (yours or a sibling module's, generic or not, and the standard library's, such as fs.ErrorKind) and over number/string (E0218), a typo'd variant arm caught as a compile error with a nearest-variant suggestion (E0220) instead of a silent catch-all, Result + the ? operator with error-type checking, a warning when a Result is discarded (E0217), @redact PII masking on the type descriptor, runtime type descriptors, reading a key out of a map rejected (E0224), == as value equality on every type, a for binding that keeps its element type, a variant in payload position dispatching on the payload's own tag rather than binding it, two arms that would answer the same tag refused as E0305 instead of one silently winning, a declared union or record passed where a string/number/bool is expected (E0211), a field typo on an annotated module-level const (E0210), and @example / @doc @run tests that run on every build, --json included.