# Changelog

Milestone numbering matches release branches: work on `milestones/vN`
releases as vN.

## 0.10.1

### Fixed

- **`ecko check` no longer fails on warnings.** It exits non-zero on
  error-severity findings only - which is what `docs/cli/check.md` has always
  documented ("Warnings alone do not change the exit status"). The
  implementation treated every finding as fatal, so a warning gated any build
  using `ecko check` as a CI step.

  This was not theoretical: `shadows-builtin` fires on a package that exports
  `get`, `set` or `push` - its own message calls the shadowing deliberate - and
  there is no way to silence one finding. `cache`, `heap`, `html` and `xml`
  passed the gate at 0.9.5 and failed it at 0.10.0 with no change to their code,
  leaving them unreleasable short of renaming their public API.

  Warnings are still printed, and a run ending with warnings and no errors now
  says so, so a green exit beside printed findings is not mistaken for a clean
  board. `ecko check --strict` restores "any finding fails" for projects that
  want it. The pre-run gate on `ecko file.ecko` is unchanged: it has always
  refused to start only on error-severity findings.

### Added

- **`ecko fix --migrate` rewrites deprecated syntax.** Deprecated forms already
  warned (`deprecated-syntax`) and were canonicalised by `ecko fmt`; now there is
  a migrator that touches only what is deprecated, so the diff is the migration
  rather than a whole-file reflow:

  ```bash
  ecko fix --migrate --list          # what can be migrated, and what runs by default
  ecko fix --migrate --check src/    # report, write nothing (exit 2 if any file needs it)
  ecko fix --migrate src/            # rewrite in place
  ecko fix --migrate --only=const src/
  ```

  Registered today: `const` -> `let`, and `|params| body` -> `fn(params) body`.
  Adding the next one is one entry in the registry.

  **One-time codemods are now opt-in.** `--only=bytes` and `--only=exports`
  belong to specific past breaking releases and are not in the default set. A
  deprecation rewrite is a canonicalisation, so canonical source is a fixed point;
  a codemod is a reinterpretation and cannot tell already-migrated code from code
  that never needed it - run the bytes codemod twice and a correct
  `base64_decode`, which wants bytes, becomes `base64_decode_text`, which does
  not. The older `--migrate-bytes` and `--migrate-exports` spellings still work
  and now select their codemod from the same registry.

  A bare `ecko fix <file>` is unchanged - it is still the AI error fixer.

- **A removal gate for deprecated syntax.** `crates/ecko-core/src/edition.rs`
  carries `ACCEPT_DEPRECATED_SYNTAX` and `REMOVED_IN_MAJOR`; each deprecated parse
  path is gated on the first, so dropping the old grammar at the flagged major is
  one line plus a `compat/` rebless rather than a hunt through the parser. Once
  flipped, the old form is a parse error naming its replacement and the migrator.
  A test fails the build if Ecko reaches that major with the switch still on, so
  the removal cannot be quietly missed.

- **Path-scoped filesystem capabilities.** `fs:read` and `fs:write` may now name
  the subtree they are confined to, so a grant can say "may write its own log
  directory" instead of "may write anywhere":

  ```json
  { "dependencies": {
      "logger": { "path": "github.com/acme/logger", "version": "v1.2.0",
                  "grant": ["fs:write:./logs"] } } }
  ```

  A bare `fs:write` still means anywhere, so nothing that worked before changes.
  A relative scope resolves against the directory holding the `ecko.json` that
  wrote it, not the working directory. Requested paths are made absolute and
  symlink-resolved first, so neither `../` nor a symlink inside an allowed
  directory escapes; scopes compare by path component, so `/data` does not cover
  `/data-backup`; and a glob is checked against its literal prefix.

  A grant is clamped to what the granter holds, so a package with its own
  dependencies writes the plain `"grant": ["fs:read"]` and passes on whatever the
  root program allowed. Authority still only narrows going down the tree.
  `net`, `env` and `exec` take no scope, and `net:example.com` is now an error
  rather than a grant that quietly did nothing.

  This closes the gap where physical actuation was indistinguishable from
  ordinary file access: writing `/sys/class/gpio/export` to drive a relay needed
  exactly the same `fs:write` as rotating a log file. See
  `docs/design/path-scoped-capabilities.md`.

- A capability denial caused by a scope now carries a `path` field alongside
  `capability` and `package`, so it says which path was refused.

- `sql.open` now checks a file database's path against the scope too;
  `sql.open(":memory:")` stays pure and ungated.

- **`std.watch` - filesystem events.** `inotify` on Linux, FSEvents on macOS and
  `ReadDirectoryChangesW` on Windows, behind one surface. Layer 2 by the syscall
  bar: the only way to do this over existing primitives is to poll `fs.list_dir`
  in a loop, which is slow, misses fast changes, and is the thing being avoided.

  ```ecko
  w = watch.open("./inbox", { recursive: true })
  e = watch.next(w, 5000)     # { kind, path }, or null once the deadline passes
  watch.close(w)
  ```

  Gated on **`fs:read`** rather than a capability of its own - seeing what
  appears in a directory is reading it, and a package granted nothing must not
  be able to sidestep `fs:read` by watching instead of listing.

  `w.events` is a real channel, so a watch composes with `recv`, a spawned task
  or an SSE response rather than imposing its own loop shape. `watch.next` takes
  a deadline because a watch on a quiet directory must not be able to hang the
  program with no way out. Costs about 132 KB of binary.

- **`std.signal` - OS signal handlers.** Catch `SIGTERM` and `SIGINT` so a
  process can finish what it started instead of dying wherever it happened to
  be. The case this exists for is a rolling deploy: the orchestrator sends
  SIGTERM and waits, and a process that cannot see it dies mid-write, holding a
  queue lease, with the run's cost unrecorded.

  ```ecko
  s = signal.on()                  # default set: ["term", "int"]
  name = signal.next(s, 5000)      # "term", or null once the deadline passes
  signal.close(s)
  ```

  Layer 2 by the syscall bar - catching a signal is `sigaction(2)`, and there is
  no way to approximate it over existing primitives. No new dependency:
  `signal-hook-registry` was already in the tree via tokio.

  Gated on **`exec`**. A disposition is process-global, so a package able to
  install a handler can swallow the operator's Ctrl-C or stop an orchestrator's
  SIGTERM from being seen - process control, the same authority `os.exit` needs.

  Names are lowercase without the `SIG` prefix (`int`, `term`, `hup`, `quit`,
  `usr1`, `usr2`); `signal.names()` reports what the platform delivers. Windows
  has no POSIX signals, so only `int` exists there and the rest are refused by
  name rather than accepted and never fired. `SIGKILL` is absent because nothing
  can catch it.

  `std.http`'s graceful shutdown now subscribes through this module instead of
  installing its own handler. Its behaviour is unchanged - still SIGINT only,
  still force-exit on a second press - but the two can no longer race for the
  process-wide disposition, which they would have done the moment a program
  called `signal.on(["int"])` while serving. That race is this card's own use
  case, so it had to be part of the change rather than a follow-up.

- **`std.archive` - zip and tar containers.** Create, list and extract `.zip`,
  `.tar` and `.tar.gz`; `std.zlib` only did raw gzip/deflate.

  ```ecko
  archive.zip_create("out.zip", ["src/", "README.md"])
  archive.zip_extract("out.zip", "dest/")
  archive.tar_create("out.tar.gz", ["src/"])   # gzip follows the filename
  ```

  Layer 2, and the reason is extraction safety rather than the formats - a zip
  is deflate plus a directory and a tar is 512-byte headers, both expressible
  over `std.zlib`. What is not worth reimplementing per package is **path
  containment**: an entry named `../../../.ssh/authorized_keys` writes outside
  the destination unless something stops it, and the failure mode is arbitrary
  file write. `..`, absolute paths and drive prefixes are refused rather than
  sanitised, tar symlink and hard-link entries are skipped, and the running
  total of extracted bytes is capped (`ECKO_ARCHIVE_MAX_UNPACKED`, default
  1 GiB). `zip` was already compiled in for `ecko pack` and `ecko build`, so
  only `tar` is new.

  Gated `fs:read` for the archive and its inputs, `fs:write` for what is
  written. `zip_create`/`tar_create` take a *list* of inputs, so their read
  check happens inside the native where the paths are known - a package scoped
  to one directory cannot archive files outside it.

  Entry names are relative, rooted at the input's last component, and sorted -
  so the same tree produces the same archive, and an absolute input does not
  produce an archive that extraction would then refuse.

- **`std.proc` - child processes with a timeout that reaps the whole tree.**
  `os.exec` runs to completion with no deadline, and killing a process kills
  only that process, so a timeout around `sh -c "ffmpeg ..."` kills the shell
  and leaves ffmpeg running. Measured on a dev box before the change: killing
  one shell orphaned four `sleep` processes.

  ```ecko
  r = proc.run("ffmpeg", ["-i", "in.mp4", "out.mp4"], { timeout_ms: 5000 })
  r.timed_out                  # the deadline passed; the tree was reaped

  p = proc.spawn("server", [])
  proc.kill(p)                 # the group, not just the child
  ```

  A child is started in its own process group (`process_group(0)`) and the
  deadline signals the group with `SIGTERM`, then `SIGKILL` after a short grace
  - a process ignoring TERM is the case a timeout exists for. `timed_out` comes
  back on the result rather than as an error, so a caller can still read the
  output that arrived first.

  stdout and stderr are drained on background threads from the moment of spawn
  rather than read after waiting: a child that fills the 64 KiB pipe buffer
  blocks on write, so reading late would hang instead of timing out.

  Layer 2 by the syscall bar (`setpgid`/`killpg`), gated on `exec`. **Unix only
  for the tree guarantee** - Windows has no process groups in this sense, so
  `kill` there reaps the direct child and grandchildren survive.

### Changed

- **`Value` is 32 bytes, down from 48.** Every push, pop, clone and drop in the
  VM moves this many bytes, against 8 for a CPython stack slot, so its width is
  the per-op cost of the whole language.

  `NativeFunction` was the widest variant and set the ceiling on its own:
  `Arc<str>` (16, a fat pointer) + `usize` (8) + `bool` (8 after padding) +
  `Arc<dyn Fn>` (16, another fat pointer) = 40 bytes of payload. Narrowing those
  to `Arc<String>`, `u32` and a thin `Arc<Box<dyn Fn>>` brings the payload to 24
  and `Value` to 32, with no change to any pattern that matches the variant.

  | | 48 bytes | 32 bytes | |
  |---|---|---|---|
  | collatz | 9.47B cycles, IPC 2.95 | **8.90B**, IPC 3.30 | -6% cycles |
  | listmap | 0.35B cycles, IPC 1.67 | **0.28B**, IPC 2.06 | -20% cycles |
  | string_join | 0.70B cycles, 1.5M cache misses | **0.56B**, **0.8M** | -20% cycles, -47% misses |

  Six of eleven `bench-gate` workloads improved and none regressed. `count` now
  beats CPython (1.035x -> 0.947x) and `listmap` went 1.217x -> 1.016x, which
  also puts it ahead of Node.

  **Instructions retired went UP** (collatz +4.9%, loop_sum +6.0%) while time
  went down, because the thin pointer costs one extra load per native call but
  the machine moves a third less data and stalls far less. Instruction count is
  the right metric for a change that removes work; it is the wrong one for a
  change that moves fewer bytes. Cycles and IPC capture both.

- **Native builtins take a slice of the VM stack instead of an owned `Vec`.**
  Every call did `stack.split_off(len - argc)` to build a `Vec<Value>` for the
  native signature, and `split_off` always allocates. Measured on a loop calling
  `abs(i)`:

  | | before | after |
  |---|---|---|
  | per native call | 358 instructions | **155** |
  | per native call | 19.4 ns | **9.5 ns** |

  A profile of a native-heavy loop had 23% of it in malloc/free. The signature
  is now `Fn(&mut [Value], &Env)`; arguments stay where they already were, on
  the stack, and the frame truncates afterwards - on the error path too, so an
  erroring native leaves the stack where the frame below expects it.

  This taxed all ~90 builtins. `dict_ops` (600k native calls) is **-6.7%
  instructions**, and `strjoin` went 1.5x -> 1.364x against CPython, recovering
  most of the regression accepted for Arc-backed strings. Workloads with no
  native calls in their hot loop are unchanged - `loop_sum` 0.0%, `collatz`
  +0.3%, measured by instructions retired, because wall clock showed +/-9%
  swings on those same programs.

  Natives that accept **named** arguments still build an owned `Vec`: they
  append a trailing options map, so the argument list has to grow and cannot be
  a borrow of the stack. That is the rare path.

- **Loop bodies no longer build a value in order to throw it away.** A block
  always left exactly one value, synthesising `Const(Null)` when its last
  statement produced nothing, and every loop body then popped it. That pair was
  two of the seven ops in a `while i < n { s = s + i; i = i + 1 }` body, paid on
  every iteration. Statements are now compiled in *statement position*, where
  the value is never emitted:

  | benchmark | before | after | |
  |---|---|---|---|
  | loop_sum (30M iterations) | 1024ms | 698ms | -32% |
  | primes (trial division to 150k) | 367ms | 263ms | -29% |
  | collatz (1..300k) | 2814ms | 2134ms | -24% |

  Call-bound and allocation-bound workloads (fib, list_map_sum, string_join,
  dict_ops) are unchanged, as expected - they were never paying this.

- **Integer division now takes the arithmetic fast path.** `fused_arith` had
  fast paths for `+`, `-`, `*` and `%` but not `/`, so every integer division
  fell through to the generic binop - including a Decimal/Float type check that
  can never apply to two `Int`s. Profiling collatz put that check alone at 3.7%.
  Division by zero and `i64::MIN / -1` still reach the slow path that words
  their errors, because `checked_div` returns `None` for both.

- **`x = x OP k` for a constant `k` compiles to one op.** It was
  `BinLocalConst` followed by `SetLocal` - computing into the stack and
  immediately popping back into the same slot. `IncLocal` already did this for
  `+`; this generalises it to `-`, `*`, `/` and `%`.

  Together with the statement-position change, collatz is **2814ms -> 1939ms
  (-31%)** and its executed instruction count is down 13%. The two are
  independent and additive: measured separately, the division fast path is
  -6.7% of instructions and the fused store -6.4%. Neither changes any other
  benchmark's instruction count, which is how they were told apart from noise -
  wall-clock alone showed +/-2% swings on workloads whose instruction counts
  were byte-identical.

  A peephole over `Const; Pop` would have been wrong: both arms of an `if` jump
  to the discard point, so deleting it corrupts those jump targets. Not emitting
  the value is the correct form. No semantic change - a loop is still an
  expression evaluating to `null`, and a block in value position still yields
  its last expression.

- **String values are Arc-backed copy-on-write**, like the collections already
  were. `Value::String` held a plain `String`, so cloning a string value copied
  the bytes - and a string constant pushed inside a loop was reallocated on every
  iteration. Cloning is now a refcount bump.

  | | before | after | |
  |---|---|---|---|
  | string_join, time | 240ms | 158ms | -34% |
  | string_join, instructions | 2.01B | 1.14B | -43% |
  | string_join, peak RSS | 170 MB | 109 MB | -36% |

  `Arc::make_mut` copies only when the buffer is shared, so `acc = acc + x` still
  appends in place and stays linear - the O(n) string builder is unaffected,
  verified across 200k/400k/800k iterations.

  One real cost: building a string now allocates the `Arc` box as well as the
  buffer, which shows on code that creates many short-lived strings and never
  clones them. `dict_ops` regressed 6.8% on that; routing map-key insertion
  through a move that unwraps the `Arc` when it is uniquely owned brought it back
  to 1.4%, which is the inherent cost of the second allocation.

  `Value::str(s)` is the constructor to use for new code; `Value::String` remains
  for pattern matching.

  **The perf baseline was deliberately rebaselined for this.**
  `scripts/bench-gate.sh` flagged `strjoin` (200k `push` of `"s" + string(i)`,
  then `join`) going 1.256x -> 1.5x against CPython: 20% of that workload is in
  the allocator, and an `Arc<String>` costs one allocation more to build than a
  bare `String`. It creates 400k short-lived strings it never clones, so it pays
  the cost and collects none of the benefit. 13ms in absolute terms.

  Accepted because the same change takes the 2M map+join idiom from 237ms to
  156ms and its peak RSS from 170 MB to 110 MB - the worst memory result in the
  cross-language suite. Eight of eleven gate workloads improved (intloop
  0.465x -> 0.333x, collatz 0.865x -> 0.548x, primes 0.853x -> 0.636x).
  `Arc<str>` would have been one allocation instead of two, and would have put
  the O(n) string builder back to O(n^2).

- **`Op::Concat` does one allocation instead of three.** It copied the left side
  with `to_string()`, copied the right side to append it, then wrapped the
  result. It now reuses the left buffer when it owns it and appends a string
  right-hand side by reference. The AST evaluator's concat path lost the same
  wasted copy.

- **A lazy range is no longer capped at 10M elements.** The cap sat on range
  *construction*, but both lazy paths allocate nothing: `for i in 0..30000000`
  and `sum(0..30000000)` peak at 9 MB, while only `list(0..10000000)` actually
  builds anything (466 MB). So a 30M-iteration `for` loop simply could not be
  written, and the workaround was hand-rolling a `while` with its own counter.

  The cap now lives where the allocation is, and says what to do instead:

  ```
  range 0..30000000 is too large to turn into a list (30000000 elements,
  max 10000000) - iterate it instead: a `for` loop or a range-aware builtin
  like `sum` or `count` streams it without building the list
  ```

  Range equality is now lazy too: comparing two 20M ranges used to materialise
  both sides, allocating ~2 GB to answer a question about four integers.

### Fixed

- **`proc.run` could lose a fast child's output.** `try_wait` reporting that a
  process exited says nothing about whether the thread draining its pipe has
  caught up, so a child that printed and exited immediately could be read as
  having printed nothing. It showed up as an intermittent CI failure rather than
  locally, which is the usual shape for this.

  The drain threads now signal EOF and the read waits for it, instead of the
  timeout path's previous sleep-and-hope. The wait is bounded at 250ms because a
  grandchild that inherited the pipe keeps the write end open after its parent
  exits, so EOF may never arrive - waiting forever would turn a finished process
  into a hang. The child's own exit closes its end, which is the case that
  matters and resolves in microseconds.

- **`ecko fmt` now breaks three constructs it used to leave at any width.**
  Sweeping every stdlib package, 44 lines over the 100-column budget survived
  `ecko fmt`; that is now 15, of which 11 are single long string literals that
  cannot be reflowed at all.

  Three separate causes, each a place where a construct never offered its
  sub-expression to the width check:

  - `expr_fit` judged a flat form on its **first line only**, on the stated
    assumption that a multi-line flat form must contain a string literal. It can
    also span lines because a nested block rendered multi-line, and those later
    lines are ordinary code. An `else if` chain whose first arm broke could trail
    a 179-character line past the budget unnoticed. It now judges the widest
    line; an unbreakable literal is still left alone, because `expr_broken`
    declines and the flat form stands.
  - **A lambda never broke.** `expr_broken` had no `Lambda` arm, so
    `fn(v) if .. { .. } else { .. }` stayed on one line however wide - 17 such
    lines in `stdlib/validate/validators.ecko` alone.
  - **A `for` header rendered its iterable flat.** A list that breaks happily as
    `xs = [..]` (22 chars) stayed at 118 inside `for x in [..]`.

  No file in this repo changes shape: `examples/` still passes `fmt --check`
  untouched, the compat corpus is byte-identical, and all 30 stdlib packages
  still pass their own tests.

- **`fs.copy` never required read access to its source.** It was gated on
  `fs:write` alone, so a package granted only write access could read any file
  the process could reach by copying it somewhere writable. It now requires
  `fs:read` on the source as well as `fs:write` on the target. A package that
  copies files and holds only `fs:write` will need `fs:read` added to its grant.
  Found while enumerating which arguments carry paths for path scoping.

- **`ecko fmt` no longer flattens a comment after the last statement.** Trailing
  comments went through a path that trimmed their text, so an indented block - a
  commented-out snippet, say - came back with its nesting gone, while the same
  lines between two statements kept theirs. The two paths now agree, which also
  makes a blank comment line render as `#` rather than `# ` everywhere.


## 0.10.0

### Added

- **`set`, `drop`, `slice` and `sort_with` builtins.** `set(coll, key, value)`
  replaces an element, where `insert` splices a new one in and grows the list -
  reaching for `insert` when you meant `set` was silently changing the length.
  `drop(list, n)` is the complement of `take`, so `take(n) + drop(n)` rebuilds
  the input, and `slice(list, start, end)` is the half-open range with clamped
  bounds. `sort_with(list, fn(a, b) -> Int)` sorts by a comparator, for orders
  that cannot be expressed as a sort key.

- **`int`, `round`, `floor` and `ceil` accept a decimal.** They return an `Int`
  exactly, without routing through a float. `round` takes halves away from zero
  rather than the half-to-even a statistics library would use, because money
  does not work that way. `float(aDecimal)` stays rejected, for the same reason
  `decimal(aFloat)` is.

- **`fmt.fixed(value, places)`** renders a number to a set number of decimal
  places. A decimal is rounded in decimal, so `fmt.fixed(decimal("0.07"), 2)` is
  `"0.07"` and not a float artefact. Halves go away from zero on both the float
  and decimal paths, which Rust's own `{:.n}` formatting does not do.

- **A lambda body can be a map literal.** `fn(c) { value: c }` returns a map
  rather than failing to parse: no statement can begin with `name :`, so the
  brace is unambiguous. The `|x| { ... }` form always worked; only `fn(...)`
  did not. `fn(x) ({ ... })` is still valid.

### Fixed

- **`ecko check` now warns when a declaration shadows a builtin.** Defining
  `fn split(m, n)` takes over every `split(...)` call in the file, including the
  ones that meant the builtin - which failed as `Can't compare string and int`
  from a line that looked unrelated. The warning fires only when the name is
  actually called, matching the existing rule for shadowing imports, and points
  at `core.split` as the way through.

- **The head of a `|>` pipeline is wrapped like any other expression.** It was
  the one expression in the language that never broke, however wide it got: the
  same call wrapped correctly on its own and stayed on a 177-character line once
  piped, because pipeline stages were always rendered flat.


### Changed

- **`http.serve` now runs on hyper instead of tiny_http.** Nothing an Ecko
  program can observe about a request or a response changed: the request map,
  the streaming `{ status, headers, stream: ch }` shape, header sanitisation,
  `Server: ecko`, the body cap, handler panic isolation and the 500/503 mapping
  are all the same code as before, now shared behind one engine-neutral seam.
  What changed is the machine underneath, which unblocked three things at once
  and brought several protocol features with it. `ECKO_HTTP_WORKERS` keeps its
  meaning - it caps how many handler invocations run at once - though it now
  sizes a blocking pool rather than spawning that many accept threads.

  The release binary drops from 18,014,976 to 17,583,936 bytes, because the
  duplicate `rustls 0.20.9`, `ring 0.16.20` and `webpki 0.22.4` that only
  tiny_http needed leave the shipped dependency graph. Throughput on the
  non-keep-alive path is unchanged (18,808 vs 18,266 req/s single worker,
  measured against a build of the previous commit on the same machine).

### Added

- **HTTP/2**, negotiated over TLS via ALPN, with HTTP/1.1 fallback for clients
  that cannot speak it. Cleartext h2c is not offered.
- **Chunked request bodies, `100-continue`, trailers and request pipelining**,
  all of which the new server handles natively.
- **Response compression.** Buffered responses over 1 KiB are gzip- or
  deflate-encoded when the client's `Accept-Encoding` asks for it, gzip
  preferred, adding `Content-Encoding` and `Vary: Accept-Encoding`. Already
  compressed content types are skipped, as is any response whose handler set its
  own `Content-Encoding`. Streaming responses are never compressed: buffering an
  SSE stream to compress it would defeat the purpose.
- **`ECKO_HTTP_READ_TIMEOUT_MS`** (default 30000), a per-connection deadline for
  receiving request headers.

### Fixed

- **Calling an `async fn` no longer leaks its captured environment.** Spawning a
  task deep-copies the environment so the task can mutate globals privately, and
  a function binding inside that copy closes back over the copy itself. The
  scope owned the closure and the closure owned the scope, which is a reference
  cycle that reference counting can never collect, so every call leaked its
  whole snapshot: about 25 KB, growing with the number of functions in scope.

  Worst on long-running servers, because feeding a channel from an `async fn` is
  the documented way to stream. A streaming benchmark grew from 10 MB to 231 MB
  over 8,000 requests and never came back; it now settles under 20 MB. A program
  making 40,000 async calls peaked at 998 MB and now peaks at 8.8 MB.

  A finished task now tears its snapshot down explicitly, but only after
  checking that nothing outside still points into it. A task may hand part of
  its snapshot out, by returning a closure or storing one in a shared `cell`, so
  the teardown counts the graph's references to itself and leaves it alone
  unless every one is accounted for. Getting that count wrong in either
  direction skips the teardown rather than emptying a scope some live closure
  still needs.

- **`ab -k` no longer hangs against an Ecko server.** An HTTP/1.0 request
  carrying `Connection: Keep-Alive` was honoured - the socket stayed open and
  the next request was served - but the response never said so, and HTTP/1.0
  clients key off that header, so they waited forever. ApacheBench speaks
  HTTP/1.0, so `ab -k` completed zero requests even at concurrency 1. The
  header could not be sent at all under the old server, which filtered
  `Connection` out of every response with no public API to bypass it.
  `ab -k -n 3000 -c 10` now completes 3000 of 3000.

- **Slow clients are dropped instead of holding a connection.** A client that
  opened a connection and dribbled partial headers could hold it indefinitely;
  `ECKO_HTTP_REQUEST_TIMEOUT_MS` only ever bounded handler run time, not how
  slowly a request arrived. The read deadline above closes it. The test for this
  (`tests/http_slowloris.rs`) had been written and then ignored because the old
  server exposed no hook to set a deadline on an accepted connection; it now
  runs.

- **Record field types now reach `ai[T]`.** A `type` declaration's field types
  were parsed and then discarded at registration, so the JSON schema sent to a
  provider described every field of a record as a string, whatever it was
  declared as, and mock mode returned strings for `Int`, `Float` and `Bool`
  fields. Declaring `age: Int` and calling `ai[User]` told the model `age` was
  a string. Both engines now record the declared types, the schema carries
  them, and mock mode produces a schema-valid value per field. Construction
  still does not enforce them; that is a separate, breaking change.

- **A live provider's response now coerces record fields against their
  declared types, and `Option<T>` fields get a real schema and mock value.**
  Two gaps left open by the fix above. First: `coerce_json_to_type_expr`'s
  struct-building arm mapped every field of a provider's JSON response with
  plain `json_to_value`, ignoring the field's own declared type - unlike a
  bare `ai[Int]`, which parses a stringy `"34"` into `34`. A provider that
  ignored the schema hint and sent `"34"` for an `age: Int` field left it a
  String, so `n = n + r.age` still concatenated. It now coerces each field
  the same way `ai[Int]` does; a field that cannot coerce becomes `Null`,
  same as a failed top-level typed call - this is coercion, not enforcement,
  and does not itself trigger the retry loop. Second: a field typed
  `Option<T>` fell through the schema/mock generator's catch-all to the same
  permissive schema used for `json`, so `type U = U { age: Option<Int> }`
  mocked `age` as a string. The schema now sends `{"anyOf": [<schema for
  T>, {"type": "null"}]}`, and mock mode produces a schema-valid `T` value
  rather than null. `Result<T, E>` is deliberately left out of both changes:
  it is an `Ok`/`Err` ADT, not a single value shape, and far less common as a
  field type than `Option<T>`.

- **The doc-link check no longer goes blind on a whole file to excuse two
  paths.** Six documents were exempt from code-span checking because some of
  their paths name files in the sibling `../docs` repo. Exempting the file also
  stopped checking its real in-repo paths, and `docs/reference/architecture.md`
  alone cites 26 of those. Only three span occurrences actually needed an
  exemption, all in `docs-ia.md`, so they are now listed by exact path. Checked
  spans go from 101 to 138, and a renamed `crates/` or `tests/` file cited in
  `architecture.md` or `lang-spec.md` now fails the gate instead of passing it.

### Added

- **The `std.string` toolkit is frozen in the compat corpus** (`string_module`,
  taking it from 22 to 23 programs). `partition`, `rpartition`, `zfill`,
  `center` and `swapcase` all shipped after the corpus was seeded, so the
  promise that old code keeps running did not cover them. The existing
  `strings` program freezes the language's own literals and operators, which is
  a different surface, so this is an addition rather than a replacement.

### Added

- **The compat corpus grew from 19 to 22 frozen programs.** `channels_bounded`
  freezes the bounded-channel surface added after the original seed -
  `channel(n)` backpressure, a `send` that blocks while the buffer is full,
  and the non-blocking `try_recv` - none of which any prior frozen program
  exercised. `imports` freezes two things no prior frozen program pinned:
  `std.io`'s `io.print` writing without a trailing newline (no other program
  imports `std.io`), and a full `json.encode` then `json.decode` round trip
  on the success path (`errors.ecko` only calls `json.decode` down an error
  path). `doc_comments` freezes the `##` documentation-comment surface,
  previously uncovered by the corpus. Coverage only grows, per
  `compat/README.md`'s own rule; nothing existing was edited. The corpus is
  also now hash-enforced: every file under `compat/programs/` has a recorded
  hash in `compat/SHA256SUMS`, checked by `scripts/compat-freeze.sh`, so an
  unnoticed edit or an addition made without reblessing the manifest fails
  instead of passing silently.

### Fixed

- **`ecko fmt` no longer moves a trailing comment inside a string literal.** An
  ordinary `"..."` may hold a raw newline, and with interpolation the statement
  renders across two lines. The formatter put the trailing comment on the
  statement's first line, which was inside the string: quiet corruption when the
  comment was plain text, since it became part of what the program printed, and
  unparseable output when the comment contained a quote, since that closed the
  string early. The comment now goes on the first line that does not end inside
  a string.
- **The corpus fuzz test is reproducible across machines.** `corpus_mutations_never_panic`
  walked the examples with `read_dir` and never sorted, while one seeded RNG was
  consumed in that order, so which mutation hit which program depended on the
  filesystem. A failure could not be reproduced from the same commit, and any
  change to the example corpus reshuffled every mutation. The corpus is now
  sorted by path, with `corpus_is_collected_in_a_fixed_order` guarding it.

### Changed (BREAKING)

- **A record's declared field types are now enforced at construction.** The
  entry above shipped the types through to `ai[T]`'s schema and mock mode but
  left plain construction unchecked; `User("Sean", "not-a-number")` accepted
  the mismatch. It no longer does, in every constructor path: the positional
  call, the brace form, a positional call inside a bridged closure
  (`map(xs, fn(s) User(s, 0))`), and the brace form on the AST tier, which is
  what an `async` fn or a contracted fn body constructs on - both engines run
  the same check. The error names the field, the type, the expectation and
  what arrived:

  ```
  field `age` of `User` expects Int, got string
  ```

  The one asymmetry: a `Float` field accepts an `Int` (widening is lossless,
  the same reason `1 + 2.0` is a float), but an `Int` field rejects a `Float`
  (narrowing loses data). A field left out of the call is checked the same
  way `null` would be, so a missing required field now throws instead of
  silently building the struct with that field `null` - unless the field's
  declared type already accepts `null` (no annotation, or `Option<T>`).
  `Result<T, E>` fields stay unmatched, consistent with the schema gap
  above. `ecko check` reports a mismatch statically wherever the value is a
  literal, in either construction form, before the program runs, so most of
  the migration is a list of file and line numbers rather than a runtime
  surprise.

  **Assignment is checked too, by the same matcher.** `set_in_path` is the
  single function both engines share for writing into a field, so one
  check covers construction and assignment alike, with the identical
  message shown above. A nested path is checked at the field it actually
  writes, not at the root: given `type Server = Server { port: Int }` and
  `type Config = Config { server: Server }`, `c.server.port = "nope"`
  reports:

  ```
  field `port` of `Server` expects Int, got string
  ```

  A plain map has no declared field types and stays permissive - `m.a =
  "anything"` is unaffected - which is the boundary that stops enforcement
  reaching past records, and a test pins it. `Result<T, E>` fields still
  accept anything, consistent with construction above. What this still does
  not cover: index assignment. `xs[0] = "x"` on a `List<Int>` field does not
  validate the new element, because the declared type belongs to the field,
  not to each element, and checking it would mean re-validating the whole
  list on every element write. Cost: 500,000 assignments measured 127ms
  before this check and 141ms after, interleaved - about 11 percent, or 30
  nanoseconds per write, the same construction pays. See
  `docs/specs/2026-08-01-type-record-enforcement-design.md` for the full
  design record. No frozen program in `compat/programs/` changed behavior
  under this: the corpus never relied on a wrong-typed field or a
  wrong-typed assignment.

## 0.9.5

### Added

- **A JetBrains plugin** (`editors/jetbrains-ecko`) for IntelliJ IDEA, WebStorm,
  PyCharm and the rest of the family, including the free Community editions. It
  registers the `.ecko` file type, ships the same TextMate grammar the VS Code
  extension uses, and wires up `ecko lsp` through LSP4IJ for diagnostics,
  completion, hover, go-to-definition and symbols. LSP4IJ is used rather than
  the platform's own LSP API because that API ships only in the paid IDEs.
  Verified Compatible by JetBrains' plugin verifier against IntelliJ IDEA
  Community 2024.2.5.
- **A publishing and maintenance guide** for both editor integrations
  (`docs/guides/editor-extensions.md`): marketplace setup for VS Code, Open VSX
  and JetBrains, signing, the shared-grammar rule, and what to update when the
  language gains syntax.

### Fixed

- **The VS Code grammar broke on `type X = A | B`.** The union bar was read as
  the opening delimiter of a `|x|` lambda, and because that rule was an
  unbounded begin/end pair it never closed - every `let`, `fn`, string and
  keyword after the first type declaration in a file was scoped as a lambda
  parameter. Type declarations are now consumed by their own rule before the
  lambda rule runs, and the (deprecated) `|x|` form is matched as a single
  bounded token so a stray bar cannot run away again. Verified by tokenizing a
  full-syntax file with the real TextMate engine.
- **The grammar did not highlight the `ai` clauses** (`using`, `with`, `on`,
  `-> stream`), `##` documentation comments, or `std.defaults`, and it
  highlighted a `+=` operator the language does not have. Module names in
  `import std.<name>` position are now scoped as modules rather than being
  unhighlighted - or, for `std.string`, mis-scoped as the `string()` builtin.
- **`is_blank` was documented as a global builtin.** It is `string.is_blank`.
- **`ecko doc` documented a multi-file package as an empty page.** A package
  that curates its public surface in `main.ecko` and keeps the implementation
  in siblings got a title and nothing else, because extraction read one file
  and `export * from "./impl.ecko"` is not a declaration. It now follows
  `export ... from` into those files with the same selectivity an importer
  gets: `export *` takes the whole exported surface, `export { a }` takes `a`
  and leaves its siblings private, `as` documents the alias, and private
  helpers never cross the boundary. Cycles terminate. This hit the three
  packages with the largest APIs - `webkit` (0 -> 34 entries), `validate`
  (0 -> 28) and `cache` (0 -> 7) - and it also made the package release
  workflow's "every export is documented" gate pass vacuously for them, since
  it counted holes in a surface it could not see.

- **`ecko doc <file> -o <dir>` wrote `<dir>.md`** instead of a file inside the
  directory. The directory-mirroring added for multi-file output stripped the
  source path from itself and got an empty relative path, so `-o docs` produced
  a file called `docs.md`. Single files now use their basename. Covered by a
  test that runs the CLI, which is where the gap was - the existing tests only
  exercised the library.

### Changed

- **Generated docs are titled after the package**, not the file stem. A
  `stdlib/url/docs/main.md` headed `# main` told the reader nothing; `ecko doc`
  now reads `module` (or `name`) from an `ecko.json` beside the source and uses
  its last segment, falling back to the stem when there is no manifest.

## 0.9.4 - 2026-07-29

### Added

- **Prebuilt binaries for four platforms, and an installer.** Releases ship
  linux x86-64/arm64, macOS Apple Silicon and Windows x86-64, each built
  on its own native runner (cross-compiling `native-tls` means cross-compiling
  OpenSSL, and macOS additionally needs the Apple SDK; built natively, macOS
  uses Security.framework and Windows uses schannel, so neither needs any
  setup). Every job smoke-tests its own artifact before packaging.

  Linux gets two treatments the others do not need: a new **`vendored-tls`**
  cargo feature statically links OpenSSL - without it the binary needs
  `libssl.so.3` at runtime and will not start on a distro shipping OpenSSL 1.1
  or none - and it builds on the oldest supported runner, since a glibc-linked
  binary runs on that glibc or newer but never older. The workflow asserts both.

  `scripts/install.sh` is the `curl -fsSL https://ecko.sh/install | sh` target:
  POSIX sh (Debian's `/bin/sh` is dash), detects OS and architecture, verifies
  the checksum **before** unpacking, and never sudoes on the user's behalf. It
  makes **no API calls**: assets are named without a version and served from
  `ecko.sh/dl/latest/`, so installing is a single request with no version
  lookup and nothing to rate-limit.

  Artifacts publish to **Cloudflare R2**, served from `ecko.sh/dl`, because
  this repo is private and its release assets 404 for anonymous clients - the
  installer could not otherwise fetch them. Every release is written to an
  immutable `dl/<tag>/`; only full releases are copied to `dl/latest/`, so a
  pre-release is installable on purpose and never by accident. Publishing is
  refused unless every planned platform built.

  A `-test` tag builds Linux x86-64 only, so the pipeline can be exercised
  cheaply; `-rc.N` and plain version tags build all four, because a candidate
  that has not been built on Windows is not a candidate.

  Intel macOS is deliberately absent: the `macos-13` runner is being retired
  and no longer schedules, and an Apple Silicon binary cannot run on an Intel
  Mac (Rosetta translates the other way), so there is no fallback to offer.
  The installer says so outright instead of 404ing.

- **`##` doc comments and `ecko doc`.** A doubled comment marker is
  documentation: `## Fetch a user.` above a declaration documents it, and
  `ecko doc <file|dir> [-o <dir>]` renders markdown from it. Doubling `#`
  rather than adding a second comment syntax (`///` has no `//` to build on in
  a language where `/` is only division) keeps one rule for comments. An
  `example:` line followed by indented lines becomes a fenced code block.
  Attachment is positional: a block documents the declaration below it, a blank
  line detaches it, and an unattached block at the top of a file documents the
  module. Exported declarations are listed whether documented or not, so gaps
  in the public surface show up; undocumented private helpers are omitted.
  Extraction is a separate pass (lex for comments, parse for declarations, join
  by line), so the AST carries no documentation and nothing about running a
  program changed.

  The formatter previously rewrote `## x` to `# # x`, which would have
  destroyed every doc comment in a file; it now preserves the marker across all
  three of its comment-emission paths.

- **`ecko.json` loads automatically, and `std.defaults` exposes it.** The
  manifest sitting next to the file being run is now read before the program
  starts: its optional `environment` object is applied to the process
  environment, and every other top-level key becomes a member of
  `std.defaults` (`defaults.version`), keeping its JSON type. Every key must be
  a valid identifier - JSON allows `"api-url"`, but `defaults.api-url` is a
  subtraction, so such a file is refused when it loads rather than supplying
  values nothing can name; the same rule covers `environment` names.
  `environment` deliberately *overrides* the surrounding shell - the block
  exists to pin the environment a project expects - and because it is
  applied before evaluation it configures Ecko's own settings
  (`ECKO_AI_PROVIDER`, `ECKO_MAX_DEPTH`, ...) too. The manifest reader is
  unchanged and independent: it stays strict about what a *package* needs,
  while the defaults reader accepts any JSON object, so a project that only
  wants defaults never has to satisfy the manifest schema. Values must be a
  string, number or boolean under `environment`; an object or list is an error
  rather than a silent JSON encoding.

- **Modules can be indexed by string.** `module["name"]` performs the same
  lookup as `module.name`, in both engines - the form that reaches a member
  whose name is only known at runtime, as `std.defaults` keys often are.

- **`os.arch()` and `os.family()`** complete host detection alongside the
  existing `os.platform()`: the CPU architecture (`x86_64`, `aarch64`, ...)
  and the OS family (`unix` or `windows`, usually the right thing to branch
  on for path separators, shells and line endings). Both are constants fixed
  when the binary was built, so they cost nothing at runtime and cannot be
  derived from Ecko any other way. Ungated - unlike `os.env`, which can carry
  secrets, which machine you are on is not sensitive.

- **Map literals accept quoted keys.** `{ "set-cookie": v }` used to be a parse
  error - keys had to be bare identifiers, so any key with a hyphen, dot or
  space (that is, most HTTP headers) had to be built with nested
  `insert(empty_map(), ...)` calls. Bare and quoted forms name the same key;
  a quoted key is taken verbatim (no `{expr}` interpolation in key position),
  and `ecko fmt` renders keys bare wherever they are plain identifiers.

- **`string.ord` / `string.chr`** - character to Unicode scalar value and back.
  There was previously no way to reach a character's numeric value at all,
  which blocked hashing, custom encodings and base-N conversion. Round-trips
  for non-ASCII: `chr(ord("🦀")) == "🦀"`.

- **Inclusive slices: `s[a..=b]`.** Slicing now accepts the inclusive-end range
  form everywhere slices work (strings, lists, bytes; both engines):
  `"hello"[0..=2]` -> `"hel"`, `xs[1..=2]`, `s[..=2]`, and inclusive of a
  negative end (`s[1..=-1]` reaches the last char). `..=` without an end is a
  clear parse error. Slicing semantics (negative indices, clamping, open ends)
  are now documented in the language spec.

- **`std.string` gains `rsplit(s, sep, limit?)`** - split from the right with
  the limit counted from the end, parts returned in order:
  `string.rsplit("a,b,c", ",", 1)` -> `["a,b", "c"]`.

- **`std.string` `partition` / `rpartition`:** split once around the first/last
  separator into a guaranteed 3-list `[head, sep, tail]`, so destructuring is
  total: `let (h, sep, t) = string.partition("key=val", "=")`. With no match,
  `partition` keeps the string in the head and `rpartition` in the tail
  (Python behavior); an empty separator is an error. (The global
  `partition(list, pred)` HOF is unchanged - the string form is
  module-namespaced.)

- **`std.string` character-class predicates:** `is_digit`, `is_alpha`,
  `is_alnum`, `is_space`, `is_ascii`, `is_upper`, `is_lower` - true iff the
  string is non-empty and every char is in the class (Unicode-aware, backed by
  Rust's `char::is_*`). `is_upper`/`is_lower` use the cased-char refinement
  (`"A1!"` is upper; `"123"` is neither). The empty string is uniformly
  `false`, including for `is_ascii` (a deliberate divergence from Python's
  `"".isascii() == True`).

- **`std.string` casing + alignment:** `swapcase(s)` flips every cased char;
  `eq_ignore_case(a, b)` compares case-insensitively (Unicode lowercase-based;
  full case folding is deliberately not claimed); `center(s, width, fill?)`
  pads both sides with the odd leftover on the right; `zfill(s, width)`
  zero-pads after a leading sign (`zfill("-5", 4)` -> `"-005"`).

- `@untrusted` marker (on `let` bindings and function parameters) plus the
  `untrusted-in-prompt` analyzer warning: flags a tainted value interpolated
  into an `ai` prompt outside `{input …}`. Static-only, no runtime cost. v1 is
  explicit-marker-only (a trust-boundary marker); automatic source-tainting is a
  planned follow-up.

- **Import aliasing** (card fed35981). `import <path> as <name>` chooses the
  local binding for a whole module, so it never has to collide with your own
  definitions: `import std.time as t` (keeping a local `fn time()` free),
  `import "./helpers.ecko" as h`. Mutually exclusive with the selective
  `{ … }` form (which already had per-name `as`). Works on both engines,
  round-trips through `ecko fmt`, and the analyzer resolves the aliased name.
  `tests/exports.rs`.

- **VS Code grammar 0.7.0**: catch up syntax highlighting with recent language
  surface - bitwise word operators (`band`/`bor`/`bxor`/`shl`/`shr`/`bnot`),
  `b"..."` byte-string literals, `sql { … }` query blocks with `{expr}` bind
  holes, secrets builtins (`secret`/`reveal`/`is_secret`), plus missing
  builtins (`approx`, `bytes`, `list`, `try_recv`) and std modules
  (`dns`, `image`, `zlib`). Indent rules gain `unless`/`template`; snippets for
  `sql`/`secret`/`unless`. `editors/vscode-ecko/`.

- **General binding destructuring** (card 667d6018). The strict list
  destructure from `for (k, v) in ...` now works for bindings and assignment:
  `let (a, b) = pair` and `mut (x, y) = point` bind new variables, and
  `(a, b) = pair` reassigns existing ones (handy for a one-line swap,
  `(x, y) = [y, x]`). Flat names only, two or more, no nesting; the value must
  be a list of exactly that length (a mismatch is a runtime error). `_` discards
  a position, and a single parenthesized name stays an ordinary assignment.
  Identical on both engines; `ecko fmt` round-trips it. `tests/binding_destructuring.rs`.

- **Cross-file static analysis + multi-file LSP** (card 8f448b77). `ecko check
  <file>` now resolves the file's imports (file / vendored package / std) and
  checks across module boundaries: `unresolved-import` (missing module /
  unvendored package / unknown `std.*`), `unknown-member` (a member the
  imported module doesn't export, with did-you-mean), and `arity-mismatch` on
  calls into an imported user module. Std-module arity is intentionally not
  checked (its recorded arity omits optional args - no false positives). New
  `analysis::lint_at(source, path)`; `ecko check` uses it. The LSP gains
 **workspace symbols** and **cross-file go-to-definition** (jump from
  `module.member` to its definition in the imported file). Cross-file rename
  is a follow-up. `tests/cross_file.rs`.

- **Multimodal `ai`: image input to vision models** (card 32e1384c). A new
  `on` clause attaches image input to an `ai` call - `ai "describe" on img`,
  or `on [a, b]` for several - where the image is a `std.image` handle (so a
  `resize`/`crop` pipeline flows straight in). Composes with typed output
  (`ai[Kind] "..." on img`). Live, the image serializes into the provider
  message content (OpenAI `image_url` data-URLs, Anthropic base64 `image`
  blocks); the same code runs on either. Offline/mock mode echoes each
  image's real dimensions (`[AI Mock] describe [image 4x2]`), so vision
  pipelines stay deterministic and testable without a key. Combining `on`
  with tools / session / voting / streaming is a follow-up (a clear parse
  error for now). Kernel `ImageResolver` hook (mirrors `LiveLlm`, installed
  by ecko-std). `tests/multimodal.rs`, `examples/vision.ecko`, LANG_SPEC
  (`### Vision`), design in `docs/design/multimodal-ai-design.md`.

- **`std.sql` transactions** (card b0656d4b): `sql.transaction(db, |tx| ...)`
  commits on success and rolls back if the closure raises (re-raising the
  error, so partial writes never persist), plus manual `sql.begin` /
  `sql.commit` / `sql.rollback`. `tests/sql_tx.rs`, `examples/sql.ecko`.

- **Multipart parser anchors on the CRLF-prefixed boundary** (RFC 7578), so a
  boundary token appearing inside binary file content no longer mis-splits an
  upload. `tests/http_form.rs`.

- **`hash.password` / `hash.verify`** (card d90224dc): Argon2id password
  hashing with a per-call random salt (self-describing PHC string) and a
  constant-time verify that returns `false` on a malformed hash. Native -
  a memory-hard KDF can't be done safely in pure Ecko. Crate `argon2`.
  `tests/password.rs`.

- **`std.web` static files + middleware** (card e729fe03): `web.static(prefix,
  dir)` serves files (content type by extension, traversal/symlink-escape
  rejected as 404), and `web.router(routes, [middleware])` runs a chain of
  `|req, next|` functions - auth gates, CORS, logging, rate limits compose by
  calling `next(req)` or short-circuiting. `tests/web_static_mw.rs`,
  `examples/web.ecko`.

- **`ecko dev <file>`** (card d2e8612e): runs a program and hot-reloads it
  whenever it or any `.ecko` file beside it changes - restart-based
  (nodemon-style), so it works for one-shot scripts *and* blocking servers.
  Polls mtimes, no new dependency.

### Changed

- **Plain-http package sources are now refused for remote hosts.** `ecko get`
  (and any archive-URL fetch) requires https: `ecko.sum` only catches
  tampering on re-fetches, so a first fetch over http was silently
  MITM-able. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) stay allowed
  for local testing, and `ECKO_ALLOW_HTTP=1` opts back in explicitly.
  Redirects are held to the same policy per hop (an https source that
  redirects to remote plain-http is refused), and redirect chains are
  capped at 10.

- **BREAKING: `ecko get <host/owner/repo>[@version]` replaces `ecko add`.**
  Packages are fetched by source path, Go-style:
  `ecko get github.com/ecko-sh/validate@v1.2.0` lists the repo's git tags,
  picks the requested tag (or the highest semver tag when no `@version` is
  given), vendors the package, and pins its file-tree hash in `ecko.sum`. An
  explicit archive URL or local path still works as an escape hatch.
  Publishing a package is now just cutting a git tag
  (`git tag vX.Y.Z && git push --tags`) - there is no registry to publish to.
  (A hosted-registry direction - an `ecko add <name>` index client and
  `ecko publish` - was briefly built during this cycle and dropped, unreleased,
  in favor of fully decentralized path fetching.)

- **Package integrity is now fail-closed.** A *sum-managed* project (one that has
  an `ecko.sum` file) must pin every package it imports: importing - or
  `ecko install`ing - a package with no `ecko.sum` entry is a hard error (run
  `ecko get` to pin it), and a tampered `vendor/` tree is caught at import, not
  just at install. A project with **no** `ecko.sum` is *unmanaged* and skips
  verification, so hand-placed `vendor/` and quick scripts are unaffected. A
  project becomes sum-managed the first `ecko get` that writes an `ecko.sum`.
  `ecko.sum` is now the sole integrity source (the redundant `ecko.lock` hash was
  dropped).

- **`const` is deprecated** (syntax consolidation). It was always identical to
  `let` (both immutable - they compile the same), so it added a keyword without
  adding meaning. `ecko check` warns (`deprecated-syntax`) and `ecko fmt`
  rewrites `const` to `let`; a future major removes the keyword.

- **The `|params| body` lambda form is deprecated** (syntax consolidation).
  `fn(params) body` is canonical - it reads plainly as a function. The `|...|`
  pipe form still parses and runs, but `ecko check` now warns
  (`deprecated-syntax`) and `ecko fmt` rewrites it to `fn(...)`; a future major
  removes it. Named functions (`fn name(params) = body`) are unaffected.

- **BREAKING: modules are private by default; `export` is a per-definition
  modifier.** Mark a definition public inline (`export fn`, `export type`,
  `export x = …`) instead of the separate `export { … }` list (removed). A
  top-level definition is now private to its file unless `export`ed. Compose
  multi-file packages with **re-export**, which surfaces another module's
  exports without binding it locally: `export * from "./m.ecko"`,
  `export { a, b as c } from "./m.ecko"`, and `export import "./m.ecko"` (bind
  locally *and* re-export). Re-export collisions are hard errors. A definition
  may share a builtin's name (e.g. a package `get`); reach the shadowed builtin
  as `core.<name>`, and `ecko check` warns on the shadow. Migrate old code with
  `ecko fix --migrate-exports`.

- **reqwest now uses native-tls (OpenSSL) instead of rustls/aws-lc-rs.** Its
  default TLS backend pulled the heavy `aws-lc-sys` C crypto library into every
  binary; switching to native-tls (already linked via tungstenite) drops that
  whole stack. Raw-socket TLS keeps ring-backed rustls, so the build carries
  one crypto stack, not two - a smaller binary, and the test link fits CI's
  linker (which was OOM/SIGBUS-ing on the oversized static link).

- **`std.zlib`** (card f1cc70e0): `gzip`/`gunzip`/`deflate`/`inflate` over
  bytes and strings, via `flate2` (already in the tree through `zip`, so ~zero
  added binary size). Compression is bytes-native (a string compresses its
  UTF-8 bytes; decompression returns bytes and fails loudly on bad input);
  compressors take an optional level 0-9; all pure (no capability). Pairs with
  `std.http` content-encoding. `tests/zlib.rs`; lang-spec ("Compression");
  `examples/zlib.ecko`. (zstd deferred - a separate C dependency.)

- **`std.image`** (card bc60466a): decode/load/resize/crop/encode/save for
  PNG and JPEG, over the `image` crate - the groundwork for multimodal `ai`
  (image inputs to vision models). Images are opaque integer handles (a
  process registry, like `std.net`/`std.sql` - the kernel is untouched);
  `image.encode` returns `bytes`, pairing with the bytes type and
  `std.encoding` for a base64 round trip. `load`/`save` are capability-gated
  (`fs:read`/`fs:write`); decode/encode/resize/crop are pure. `tests/image.rs`;
  lang-spec ("Images"); `examples/image.ecko` (offline, embedded base64 PNG).

- **`ecko build` bundles run fully in memory** (card b314c692): the embedded
  payload is served straight to the import machinery (a virtual-filesystem
  overlay in `interpreter::vfs` that user and package imports consult before
  the real filesystem) - no temp extraction, no cache directories, no disk
  writes at all, which also retires the extraction cache's tampering surface.
  Vendored packages, capability grants, and relative imports all resolve from
  the payload. Release binaries are now symbol-stripped and built with fat LTO
  + a single codegen unit (every MB of interpreter is a MB in every bundle;
  together these took the release binary from ~24 MB unstripped to 18 MB, with
  no perf cost). Windows note: appending the payload invalidates an
  Authenticode signature - sign the bundled output. `tests/build.rs`.

- **for-loop destructuring** (card 22e72f3d): `for (k, v) in map`,
  `for (i, x) in enumerate(xs)`, and any list-of-lists bind each item's
  elements directly instead of `pair[0]`/`pair[1]` indexing. Two or more
  parenthesized names; strict per item (a list with exactly that many
  elements, else a loud error); `_` discards a position. Purely additive -
  `for (x)` was a parse error before and stays one. Both engines, fmt
  round-trip, compat corpus untouched. `tests/for_destructuring.rs`;
  lang-spec Control Flow; `examples/maps.ecko`.

- **Bounded channels + `try_recv`** (card c5758f83): `channel(n)` bounds the
  buffer to n values - `send` blocks cooperatively while it's full (real
  backpressure for producer/consumer pipelines), is cancellation-aware like
  `recv`, and wakes as receivers drain or the channel closes. `try_recv(ch)`
  is the non-blocking receive: the next value, or `null` immediately (the
  same null-on-close convention as `recv`). `channel()` stays unbounded and
  unchanged. `tests/bounded_channels.rs`; lang-spec Channels section;
  `examples/channels.ecko`.

- **`bytes` type + word bitwise operators** (card b930fbd4, design
  `docs/design/bytes-bitwise-design.md` D1-D5): `Value::Bytes` with `b"..."`
  literals (`\xNN` escapes, no interpolation), `band bor bxor shl shr bnot`
  (checked shifts; element-wise on equal-length bytes), index/slice/iterate/
  concat/compare/match-pattern support, an explicit text/bytes boundary
  (`bytes()`, strict `string()`, `string.from_utf8[_lossy]`, `list()`),
  binary-safe `contains`/`index_of`, and JSON/`ai[bytes]` as base64.
  `tests/bytes.rs`; lang-spec "Bytes & Bitwise Operators"; `examples/bytes.ecko`.

- **Std accepts bytes** (stage 2, card fd07a325): `encoding.base64_encode` /
  `hex_encode`, `hash.sha256` / `hmac_sha256` (plus raw-digest
  `sha256_bytes` / `hmac_sha256_bytes`), `net.send`, and `fs.write` accept
  bytes (strings keep meaning their UTF-8 bytes); new `fs.read_bytes`.
  `tests/bytes_std.rs`.

### Changed (BREAKING)

- **`std.sql` BLOB columns return `bytes`**: a SQLite `BLOB` value now maps to
  `Value::Bytes` (exact binary) instead of a lossy `from_utf8_lossy` string
  that corrupted non-UTF-8 data - the same silent-corruption class the bytes
  migration fixed elsewhere. Binding a `bytes` parameter now stores a `BLOB`
  (it previously became a JSON/base64 string). Callers that stored text in a
  BLOB column and read it back as a string should wrap the read in `string()`.
  `tests/sql.rs`.

- **Decoders and `net.recv` return `bytes`** (stage 3, card 8ac94272; decided
  pre-1.0 so v1.0 ships without the silent-corruption behavior):
  `encoding.base64_decode` / `hex_decode` and `net.recv` now return the exact
  bytes instead of a lossy UTF-8 string that corrupted binary data. The
  explicit text forms - `encoding.base64_decode_text` / `hex_decode_text` /
  `net.recv_text` - behave identically on valid UTF-8 and error loudly
  otherwise. Migrate mechanically with `ecko fix --migrate-bytes [--check]
  <paths>` (lexer-based: rewrites `*_decode` -> `*_decode_text` and
  `net.recv` -> `net.recv_text`, leaving strings/comments/channel `recv`
  untouched).

- **New reserved words**: `band`, `bor`, `bxor`, `shl`, `shr`, `bnot` are now
  keywords (like `and`/`or`/`not`) and can no longer be used as identifiers;
  they remain usable as field names after `.`.

### Fixed

- **`ecko check` no longer rebuilds the builtin surface per file.** The
  resolver constructed an `Environment` and ran all ~95 native registrations
  (an `Arc` and a boxed closure apiece) on every analysis - once per program
  run, and once per keystroke under the LSP. It is derived once per process
  now: checking 60 files in one invocation went 17.4ms -> 14.3ms (**-18%**),
  with byte-identical output across all 182 `.ecko` files in the workspace.
  `load_builtins` registers unconditionally and reads no environment, so there
  is nothing to invalidate. Single-run startup is unaffected either way - one
  run only resolves once.

- **Builtins refuse extra arguments.** Builtin arity was enforced as a
  *minimum* only, so `abs(-1, 99)` returned 1 and `upper("a", 99)` returned
  "A", silently dropping the extra - while the same typo in a user function was
  a hard error that refused to start the program. Calling a builtin with too
  many arguments is now an error in both engines, and `ecko check` reports it
  statically with the same `arity-mismatch` message user functions get, so the
  two surfaces finally agree about whether a mistake is a mistake. The five
  builtins with genuinely optional trailing arguments (`trim`'s charset,
  `split`'s limit, `approx`'s tolerance, `channel`'s capacity, and `cost`'s
  explicit-prices form) keep their real maxima; the analyzer reads those from
  the registrations rather than a hand-kept table, so it cannot drift.

- **`std.web`: a `web.get` route now answers HEAD.** `web.get` matched the GET
  method only, so every route fell through to the 404 arm on a HEAD request -
  and HEAD is what uptime monitors, health checks, load balancers and link
  checkers send first, so a perfectly healthy Ecko server reported itself down.
  It also made debugging misleading: `curl -I` sends HEAD, so inspecting
  response headers returned the 404 page's, and correct middleware looked
  broken. The GET handler now runs for HEAD and the body is dropped as the
  response is written, so the status and headers - including the
  `content-length` GET would have sent - are the real ones. An explicit
  `web.head(path, handler)` is available and takes precedence over the
  fallback; `web.static` serves HEAD on the same terms.

- **`ecko check` now warns when a module import shadows a builtin.**
  `import std.string` binds `string`, displacing the builtin `string()`
  conversion; the program then failed at runtime with a bare
  `Can't call module` that never mentioned the import, and `check` said
  nothing. It now reports `shadows-builtin` with the fix in the message
  (`import std.string as str`, or `core.string`). Only fires when the file
  actually calls the displaced name.

- **`ecko fmt` no longer leaves over-budget lines around multi-line strings.**
  A call whose argument was a multi-line string literal was exempt from
  wrapping entirely - the flat form contains a newline, and the fitter returned
  any such form unchanged - so the formatter un-wrapped source the author had
  already wrapped, producing lines of 120-200 columns. Width is now judged on
  the first line, and breaking is skipped only when rendering actually consumed
  comments (which cannot be rendered twice).

- **`ecko fmt` accounts for the `export ` prefix.** It is prepended after a
  statement body is rendered, so every exported statement was judged 7 columns
  narrower than it really is and could settle 7 columns over budget.

- **`trim` charset and `split` limit are honored instead of silently ignored.**
  `trim(s, chars)` / `string.trim_start` / `string.trim_end` now strip any
  character in the given set (`trim("xxhixx", "x")` -> `"hi"`), and
  `split(s, sep, limit)` caps the number of splits
  (`split("a,b,c,d", ",", 2)` -> `["a", "b", "c,d"]`). Both forms previously
  accepted the extra argument and did nothing with it; a wrong-typed extra
  argument is now an error rather than being dropped.

- **Selective import now works for file and package modules**, not just `std`.
  `import "./util" { double, greet as hi }` (and the same from a vendored
  package) used to silently ignore the `{ … }` selection and bind the whole
  module under its stem, so the selected names came out `undefined` at the use
  site. It now binds the chosen names directly (with clean errors for names the
  module doesn't export), matching `import std.x { … }`.

- **Template directives in a plain string now error clearly.** `{input …}` and
  `{for …}` are `template`-body directives; used in a plain interpolated string
  they used to silently misparse (`{input x}` read a variable `input`, erroring
  `undefined-variable`). They now produce a directive-specific parse error
  pointing at templates. A bare `{input}` variable and a genuine `{if …}`
  expression are unaffected.

- **Ollama LLM errors are now readable** (card c8960cec). The error extractor
  only understood the OpenAI/Anthropic shape (`{"error":{"message":...}}`), so
  Ollama's flat `{"error":"..."}` collapsed to an empty string - a failed call
  surfaced as e.g. `Ollama returned 404:` with nothing after the colon. A single
  `error_detail(provider, body)` helper now parses each provider's shape, falls
  back across the others (for proxies/gateways that reshape errors), and shows a
  truncated raw body rather than nothing when the shape is unrecognized. Applied
  across the chat, tool-call, streaming-fallback, and embedding paths.
  `crates/ecko-std/src/llm_live.rs`.

- **A malformed importer manifest is now a hard error, not a silent zero-grant.**
  When resolving `import <pkg>`, a *present but unparseable* `ecko.json` (e.g. a
  dependency entry missing the required `source`/`sha256`) previously had its
  parse error swallowed, defaulting the dependency's grant to `[]` - which then
  surfaced downstream as a misleading "package attempted X without capability Y"
  denial. It now halts at import naming the malformed `ecko.json` and the parse
  reason. An *absent* manifest is still fine (a loose script legitimately has no
  declared grants). `tests/capabilities.rs::a_malformed_importer_manifest_is_a_hard_error`.

- **`ecko dev` now reloads on served files, not just `.ecko`.** The file watcher
  also picks up common web/static files (`.html`, `.css`, `.js`, `.json`,
  `.svg`, `.md`, images, fonts, …), so editing the page or assets a server
  serves triggers a reload. `.ecko`-only watching meant static edits never
  reloaded. (A curated set, not every extension, so a program writing an
  unrelated file under the tree can't spin an endless reload loop.)

- **`ecko dev` shuts down cleanly on Ctrl-C.** It now installs a SIGINT handler
  that tears down the child server and waits for it before exiting, instead of
  the parent taking the default SIGINT and dying immediately - which orphaned
  the child (it kept running and printed its shutdown after the shell prompt
  returned).

- **`http.serve` binds with `SO_REUSEADDR`.** Restarting a server on the same
  port - a dev reload, or a manual Ctrl-C then rerun - now rebinds immediately
  instead of failing with "address already in use" while old connections sit in
  TIME_WAIT.

- **Structured errors survive native higher-order calls.** A thrown
  `error({ kind, ... })` caught after `map`/`filter`/`sql.transaction`/any
  native callback now reattaches its payload instead of flattening to the
  message string - `call_function` re-parks the thrown value across the
  string-only callback ABI. `crates/ecko-core/src/interpreter/evaluator.rs`.

- **Web/SaaS readiness P0** (card 39135c89) - the gaps that blocked shipping a
  SaaS on Ecko, all in `std.http` / `std.random`:
  - **Binary bodies both ways**: served responses may be `bytes`
    (`http.response(200, png_bytes)` or a bare bytes return) so files and
    images serve directly; request bodies are exposed raw as `req.body_bytes`
    (the old `from_utf8_lossy` corrupted uploads); client responses carry
    `body_bytes`. `tests/http_bytes.rs`.
  - **Form + multipart parsing**: `req.form` holds url-encoded or multipart
    text fields; `req.files` holds multipart uploads as
    `{ name, filename, content_type, data }` (data as bytes). Empty, never
    absent, otherwise. `tests/http_form.rs`.
  - **`random.bytes(n)` / `random.token(n)`**: a CSPRNG pair drawing from OS
    entropy (getrandom), distinct from the seedable RNG - safe for session
    tokens, CSRF tokens, and API keys. `token` returns url-safe base64.
    `tests/random_secure.rs`.
  - **Streaming / SSE responses**: return `{ status, headers, stream: ch }`
    from an `http.serve` handler and each value the channel yields is written
    as a body chunk until close - the Server-Sent Events substrate for
    streaming AI output to a browser. `tests/http_stream_serve.rs`,
    `examples/streaming_http.ecko`.
  - All four survive the `std.web` router (the recommended app shape) -
    uploads reach handlers as `req.files`, bytes/stream responses pass back
    out unchanged. `tests/web_router_p0.rs`.

- **REPL polish** (card 215ecbe2): multi-line input now shows a dim `··· `
  continuation marker on each fresh continuation line (rendered as a
  display-only hint, so it can never be inserted into the buffer), and eval
  errors render inline Rust-style - the offending input line with a caret
  under the column, via the same `Diagnostic` renderer the CLI uses on files.
  Location-less errors keep the one-line form. The banner is now a single
  plain uncolored ASCII line (`ecko v0.9.0 // REPL on Linux // ecko.sh`) -
  the old box-drawing frame (U+27E9, U+256D…) rendered as substituted or
  missing glyphs on fonts without them, collapsing the box into scattered
  text - and the prompt is `>>> `, green, the only colored element of the
  startup surface. Tab completion is bash-style now (complete to the common
  prefix, second TAB lists candidates) instead of circular cycling, and an
  exactly-typed name is never replaced by a longer candidate. `src/repl.rs`.

- **`ecko fmt` wraps long lines** (card 074a41d6): lines over 100 columns now
  break Prettier-style - list/map literals and call arguments go one element
  per line with trailing commas, two-stage pipelines break onto leading `|>`
  lines, same-precedence operator chains break after the operator, and
  overlong inline `if`/`try` branches fall back to block form. Broken forms
  insert newlines only where the parser ignores them, so output re-parses to
  the identical AST - wrapping is idempotent and the whole example corpus
  still runs byte-identically (the fmt gate enforces both). String literals
  are never reflowed, so a single long atom may still exceed the limit.
  Examples reformatted (`compat/` untouched, as always). `tests/fmt.rs`.

- **Keyword map keys** (T-P.2): keywords are accepted as map-literal keys and
  struct-pattern keys - `{ from: "a", type: "b" }` and
  `match m { { from: f } => ... }` now parse (previously only dot *access*
  `m.from` worked, per T-P.1, so such maps could only be built with
  `insert(m, "from", v)`). Pattern shorthand `{ from }` stays an error with a
  fix-it (bind explicitly). Disambiguation is unchanged: `name :` can't start
  a statement, so blocks like `{ match x { ... } }` still parse as blocks.
  `tests/parser.rs`.

- **`std.dns`** (card 4d2088a2): hostname resolution and typed record lookups
  above getaddrinfo - `dns.resolve(host)` → sorted IPs (A + AAAA),
  `dns.reverse(ip)` → primary PTR hostname, and `dns.lookup(host, type)` for
  A/AAAA/MX/TXT/CNAME (MX as `{priority, host}` maps sorted by priority), via
  `hickory-resolver` (net feature, `net` capability). Deterministic offline
  tier: the reserved `.test` TLD and `localhost` (plus their loopback /
  TEST-NET-1 / `2001:db8::/32` addresses) answer from a built-in fixed hosts
  table, so examples and tests never touch live DNS. `tests/dns.rs`,
  `examples/dns.ecko`, LANG_SPEC `## DNS`, compat program `dns`.

- **A bare GitHub repo URL resolves to its latest release asset** in the
  package fetcher: `ecko get https://github.com/owner/repo` (the explicit-URL
  escape hatch) fetches `…/repo/releases/latest/download/repo.zip` (the asset
  is named after the repo, the only name derivable from the URL). Full asset
  URLs, non-repo paths, and local/other sources are unchanged. Applies
  everywhere `fetch` is used (`get`/`install`/`update`). `resolve_source` in
  `src/pkgcmd.rs`.

- **`hash.sha1` / `hash.sha1_bytes`** (std.hash): SHA-1, for legacy wire
  protocols that need it (e.g. MySQL/MariaDB `mysql_native_password`). Weak by
  modern standards - not for new hashing - but the one native primitive that
  lets a pure-Ecko MySQL client exist. `tests/utils.rs`.

- **`std.net` raw-socket TLS + framing** (card d6fa668e, DB-connectivity
  Phase 0): `net.connect_tls(host, port, {verify})` (TLS from the first byte)
  and `net.starttls(conn, {verify})` (upgrade a live plaintext socket in place
  - the STARTTLS shape), plus `net.recv_exact(conn, n)` (read exactly n bytes,
  errors on early close) and `net.recv_until(conn, delim)` (read through a
  delimiter without over-reading). Certs verify against the OS trust store by
  default; `{ verify: false }` for self-signed/dev. Uses rustls 0.23 with the
  ring provider. This is the keystone for pure-Ecko database clients
  (Redis/Postgres) and useful for any TLS wire protocol (SMTP/IMAP/...).
  `tests/net_tls.rs`; lang-spec Networking. Proven by two standalone,
  installable pure-Ecko packages built entirely on this plumbing (not in this
  repo): a **Redis** client (RESP2) and a **PostgreSQL** client (v3 wire
  protocol + SCRAM-SHA-256 auth, with PBKDF2 running in the interpreter). The
  plumbing is here; the clients are normal Ecko modules.

### Performance

- **Closure calls no longer contend across threads.** Entering the VM cloned the
  program's function table and definedness Arcs on every call. Both are shared
  by every thread running the program, and the VM is entered once per
  higher-order-function element, so those two refcount bumps became cache-line
  contention severe enough that the same `map`/`reduce` workload took *longer*
  on 8 threads than on 1 (442ms -> 1018ms). `execute` now takes them from the
  `Vm` for the duration instead of cloning, and `VmCallSession` holds its own
  pair so the hot callback path skips even that.

  Measured: the 8-thread workload above went 1018ms -> 681ms, and single-thread
  HOF work improved too (442ms -> 416ms). Bench-gate ratios: `fold` 0.796x ->
  ~0.72x, `listmap` 1.292x -> ~1.20x, `count` 1.182x -> ~1.07x. On an HTTP
  handler doing 200 closure calls per request, throughput at 8 workers went
  54,000 -> 92,500 req/s (+71%) and CPU per request 172us -> 108us.

  Ruled out by measurement along the way, recorded so they are not re-attempted:
  a thread-caching allocator (tcmalloc made it worse), and per-request/per-call
  `std::env::var` reads (no measurable change).

- **`std.sql` no longer serializes every statement in the process.** The
  connection registry held one global mutex for the whole of every query, so
  two unrelated database handles still queued behind each other. Connections
  now sit behind their own mutex and the registry lock is released as soon as
  the handle is looked up. Measured: four concurrent queries on four separate
  connections went from ~203ms (fully serialized) to 41ms (the cost of one).

- **`std.sql` caches prepared statements.** Every `sql.query`/`sql.exec` call
  re-parsed and re-planned its SQL; statements are now cached per connection
  (capacity 128) and parameters rebound. Measured over 100,000 rows: inserts
  in a transaction 557k -> 1,078k rows/s, primary-key selects 177k -> 231k/s,
  indexed aggregates 136k -> 185k/s. That moves Ecko from last to ahead of
  CPython's `sqlite3` on all three, and ahead of Node's `node:sqlite` on
  aggregates.

- `http.serve` reads `ECKO_HTTP_MAX_BODY` and `ECKO_HTTP_REQUEST_TIMEOUT_MS`
  once instead of on every request, matching how the VM treats its own budget
  variables. This measured as no throughput change; it is hygiene, not a
  speedup. Both are now fixed at process start rather than re-read per request.

- **Ranges are no longer materialized by the builtins that don't need the
  elements.** `count`, `min`, `max`, `first`, `last`, and `take` each built the
  entire list a range denotes before answering - 48 bytes per element, so
  `count(1..=1000000, pred)` allocated ~48 MB to return a single integer, and
  `first(1..=2000000)` allocated ~99 MB to return `start`. `min`/`max`/`first`/
  `last` now answer from the range's bounds in constant time, `take` reads only
  the prefix it returns, and `count` streams like `sum`/`reduce` already did.
  Measured: `bench/report/count.ecko` 92.6ms -> 63.1ms (best-of-21), its
  ecko/CPython ratio 1.67x -> 1.16x, and peak RSS 53.8 MB -> 8.2 MB.

- **The AST tier builds ranges lazily too.** `eval_range` produced a
  materialized list where the VM produced a `Value::Range`, so contracted and
  `async` functions paid the full allocation. Constructing `1..=10000000` on
  that tier went from ~255ms to ~175us. The two engines now produce the same
  value, which they always claimed to.

- **In-place list builtins stop reboxing their `Arc`.** `push`, `pop`, `sort`,
  and `reverse` unwrapped to a bare `Vec` and re-wrapped, freeing and
  reallocating the control block on every call; they now mutate through
  `Arc::make_mut`, which is in-place when uniquely owned (the linear-update
  path) and copy-on-write when shared. `unique` and `flatten` take ownership of
  their input instead of cloning it. Measured: `bench/report/strjoin.ecko`
  56.1ms -> 53.0ms, ratio 1.34x -> 1.22x.

## 0.9.0 - 2026-07-11

### Added

- **`ecko build`: single self-contained executable** (card a7570174): compile a
  program to a standalone binary - a copy of the interpreter with the program
  appended (the entry file, or the whole project + `vendor/` when an `ecko.json`
  is present). `ecko build main.ecko -o app` then `./app` runs with nothing else
  installed; arguments become the program's `os.args()`, and CLI subcommands are
  disabled inside a bundle. Packaging, not compilation (no speed change, `py()`
  still needs host Python, same-platform only). Completes the Manifesto
  "one binary, ship it" story alongside the container path.
- **Flagship agent example** (card c76834c8): `examples/agent.ecko` - a single
  runnable orchestration demo wiring `std.rag`, `@tool` + `using`, typed
  `ai[Urgency]`, `@requires`/`@ensures`, `session`, `retry`, and a simulated
  `std.web` `POST /ask` handler. Fully deterministic in mock mode (corpus +
  fmt gate).
- **Anonymous async lambdas** (card 00cab2d1): `async |args| body` now creates
  a first-class async closure with the same eager task, share-nothing snapshot,
  explicit-cell sharing, cancellation, and error semantics as named
  `async fn`. `async fn(args) body` is accepted as an alias and formatted to
  the canonical pipe form. `map` returns tasks for async callbacks, while
  callback consumers requiring immediate values reject them explicitly.
- **Nested async and contracted functions** (card 78ef7d62): functions declared
  inside VM-compiled scopes may now use `async` and contracts while retaining
  lexical captures, recursion, mutable outer bindings, defaults, and escaping
  closure lifetimes across the VM/AST bridge. Async calls snapshot ordinary
  arguments and captures while preserving explicit `cell` sharing, matching
  the language's share-nothing task model.

### Fixed

- **Lexical AI tool resolution** (card 2a8c70ad): VM `ai ... using [...]`
  calls now resolve local `@tool` functions at the call site, preserving
  lexical captures and shadowing instead of consulting only the shared global
  environment.
- **Scalable parameter metadata** (card c4285953): VM defaults and named
  arguments now work beyond parameter 64 without supplied values being
  overwritten or reported missing. The common 64-or-fewer path remains
  allocation-free, and duplicate named arguments are rejected consistently by
  both execution tiers.
- **VM AI session isolation** (card 0d2b60e9): the VM-to-AI bridge now passes
  evaluated session values directly instead of storing them in the shared
  `__vm_ai_session` global, preventing hidden state leakage, user-binding
  corruption, and cross-call races. Live session calls now count against
  `ECKO_AI_MAX_CALLS`, and an unsolicited provider tool call fails explicitly
  instead of silently appending an empty assistant turn.
- **Cancellation-aware runtime waits** (card 67c03c6a): cancelling a task now
  interrupts runtime-managed blocking waits (`await`, channel `recv`/`select`
  and iteration, and streaming consumption/resolution) instead of requiring
  an unrelated loop or call checkpoint. Cancelling a parent parked on a child
  also cancels that child so both can unwind without starving a cap-one task
  pool. Blocking external syscalls remain cooperative and return only when the
  underlying call does.
- **Runtime task and WebSocket admission limits** (card 5810a9dc): queued
  async calls no longer create an unbounded set of 256 MiB-stack worker
  threads before acquiring `ECKO_MAX_TASKS`; VM closures now release their
  running-task permit while awaiting, matching the AST tier and preventing a
  cap-one deadlock; concurrent WebSocket upgrades reserve
  `ECKO_MAX_WS_CONNS` slots atomically instead of overshooting the cap.
- **Decimal/float comparison safety** (card 5810a9dc): all six comparison
  operators now reject decimal/float mixing, matching arithmetic and the
  language specification instead of silently converting the decimal to a
  binary float.

## 0.8.0 - 2026-07-09

### Added

- **Security pass on py() and the web server** (card 34831653): `py()` no
  longer risks RCE - the function name must be a plain dotted identifier
  path (validated before it reaches Python), and the Python worker resolves
  it with importlib + getattr and NO eval(), so a name built from untrusted
  data is rejected rather than executed. Web server: handler-returned
  headers are sanitized (CRLF is dropped, closing an HTTP response-splitting
  hole - tiny_http emits raw CR/LF verbatim); 5xx responses no longer leak
  internal error text (paths, limits) to the client (logged server-side,
  generic body returned); `http.serve` gained a `host:` option for
  local-only binding; WebSocket upgrades are capped (`ECKO_MAX_WS_CONNS`,
  default 1024) so they can't spawn unbounded threads; a generic `Server`
  header replaces the library fingerprint. Slowloris read-timeout and two
  LOW py() worker notes are carded (need socket-level / trusted-code work).

- **Resource limits** (card 34831653): adversarial input degrades to a
  catchable error instead of a process abort or hang. Deep expression/block
  nesting (which stack-overflowed the parser with an uncatchable SIGABRT) is
  now a parse error (`ECKO_MAX_PARSE_DEPTH`, default 128). Infinite/runaway
  recursion (which hung the VM by growing the call stack unbounded, or
  stack-overflowed the AST tier) is a catchable runtime error
  (`ECKO_MAX_DEPTH`, default 2000). Execution runs on a 256 MiB-stack thread
  so those caps have real headroom (the stack is virtual - no startup or
  memory cost measured). An opt-in loop budget (`ECKO_MAX_STEPS`, default
  unlimited) bounds runaway loops for sandboxing untrusted code.

- **Fuzzing harness + four fmt round-trip fixes** (card 34831653): a shared
  fuzz surface (`ecko::fuzz`) runs two ways - coverage-guided under
  `cargo fuzz` (nightly, `fuzz/`) and as a deterministic seeded driver on
  stable (`tests/fuzz_smoke.rs`, now in CI). Invariants: the parse pipeline
  (lexer/parser/fmt/resolver) never panics on any input, fmt output always
  re-parses, and the VM and tree-walker agree on execution-safe programs.
  It immediately found and fixed four formatter round-trip bugs, all where
  fmt emitted output the lexer couldn't read back: `@tool` descriptions
  used Rust Debug escaping (`\u{b}`), `ai` with a bracket-leading prompt
  re-read as the typed `ai[T]` form, quoted import paths lost their quotes
  when the path wasn't a bare identifier, and the plain-string escaper
  emitted `\}` which the lexer doesn't invert (non-idempotent). Two deeper
  fmt idempotence edges (comment reattachment, branch/block canonicalization)
  are carded.

- **Restructure stages 4-5 (the tail)**: the kernel's RNG is now an
  internal xoshiro256** with splitmix64 seeding - the rand crate left
  both ecko-core and ecko-std, and seeded sequences are now identical on
  every platform. parking_lot stays by measured decision (std::sync is
  ~7%% faster under read contention but poisons on panic, and a panicking
  task thread must never brick the shared environment). Kernel: 34
  transitive deps. The three-layer growth policy is recorded in CLAUDE.md.

- **Workspace split (three-layer restructure, stage 3)**: the crate is now
  ecko-core (the kernel: 43 transitive deps, zero network/database crates
  - enforced by the crate graph, not convention), ecko-std (the native
  primitive modules, self-registering, with the live LLM layer installed
  through a core hook facade), and the root ecko crate (facade + CLI).
  Same binary, same batteries, 807 tests unchanged; embedders call
  ecko::ensure_std() or the facade eval functions.

- **CI hardened**: the workflow now enforces the zero-warnings rule
  (`RUSTFLAGS=-D warnings` - a single warning fails the build), runs the
  resolver over the whole example corpus (`ecko check`, zero findings
  required), builds and unit-tests the lean `--no-default-features`
  kernel profile (the job that keeps the stage-2 feature boundary from
  rotting), and smoke-runs every benchmark non-gating. CI badge in the
  README. Superseded runs are cancelled.

- **Three-layer restructure, stages 1-2** (docs/design/core-slim-design.md):
  std modules now self-register through a module registry with declarative
  capability profiles (core no longer knows which modules exist), and the
  heavy dependency clusters are cargo features - net (tokio/reqwest/
  tiny_http/tungstenite), sql (rusqlite), tokens (tiktoken), cli
  (rustyline/lsp/zip/packaging) - ALL ON by default: the shipped binary is
  batteries-included and byte-identical. `--no-default-features` builds a
  lean mock-ai core with 73 transitive deps instead of 268.

- **`ecko update [name...]`**: re-fetches dependencies from their pinned
  sources, re-pins the sha256 in ecko.json, refreshes ecko.lock, and
  re-vendors - completing the add/install/remove/pack surface. Grants
  are authority and are never touched; a source that now serves a
  package with a different name is refused (identity-swap guard).

- **Package capability enforcement** (the package design's section 4,
  made real): a package's code may perform gated operations only if its
  effective capability set allows - `net` (http/net/ws/llm/db/web, `ai`
  even in mock mode, embed), `fs:read`/`fs:write` (fs + json/csv/toml/
  yaml file IO, read_file/write_file, sql.open on a file - `:memory:`
  stays pure), `env` (os.env), `exec` (`py()`). Grants come from the
  IMPORTER's manifest (`dependencies[dep].grant`, default none) and
  attenuate down the tree - a dep can never hold more than its parent,
  and granting a capability you don't hold is an import error. Package
  closures keep their restrictions wherever they travel; root code is
  completely unaffected. Denials are structured (`kind: "capability"`
  with `capability` and `package` fields). Unknown capability names are
  rejected at manifest parse.

- **Bridge fast path for map/filter callbacks** (perf): a reusable
  VmCallSession (one Vm for the whole loop) replaces the per-element
  Evaluator + Vm construction, and AST-tier callbacks reuse one Evaluator.
  listmap (1M-element map) drops 250ms -> 154ms (4.17x -> 2.6x vs
  CPython); error rendering is byte-identical to the generic bridge.

- **Docker guide + Dockerfile** (docs/guides/): a two-stage build (LTO'd
  binary on distroless/static + the five runtime libraries copied from
  the build stage) producing a 25 MB image with no shell or package
  manager; verified end to end, including ai-in-mock-mode inside the
  container. Keys are passed at run time, never baked into layers. A
  repo-root .dockerignore keeps the build context small.
- **VM compile decisions read the real AST** (tech-debt): the
  Debug-string scans that decided direct-call dispatch, frame boxing,
  global promotion, and resolver arity-skips are replaced by a shared
  AST visitor (`visit_program`/`assign_target_names`/`expr_creates_fn`/
  `program_features`). Kills a silent perf cliff - a string literal
  containing text like `Lambda {` (easy in prompts that embed code) used
  to disable promotion and box every frame - and defuses the latent
  miscompile risk of scans that parsed derived Debug output.
- **std.test + `ecko test`**: first-class testing. `test.case(name, fn)`
  (alias `group`) with `eq`/`ok`/`err`/`fail` asserts that throw
  structured `{ kind: "assert" }` errors; failing asserts end their case,
  later cases still run. `ecko test` discovers tests/*.ecko and
  *_test.ecko, forces mock mode (keys stripped - deterministic, offline,
  free), prints counted per-case results, and exits non-zero for CI;
  running a test file directly also fails the process on case failures.
  craaft-ecko's test.sh is deleted in favor of it (the dogfood finding
  that motivated the card).
- **Multi-core: env RwLock + full-width pmap default**: the global
  environment's root lock is now a read-write lock - by-name lookups from
  parallel tasks take shared read locks instead of convoying on one mutex.
  32 async tasks hammering global calls went 1.59s -> 0.27s (5.9x; CPU
  went from spin-bound to 30 productive cores). ECKO_MAX_PARALLEL now
  defaults to available_parallelism() instead of 8 (pmap and tool rounds
  share the knob; set it lower for rate-limited APIs). Single-threaded
  benchmarks unchanged.
- **One error dialect** (design: docs/design/error-dialect-design.md):
  operational stdlib failures now throw structured `{ kind, message, ... }`
  maps you can match on - kinds: `parse` (json/csv/toml/yaml/regex, with
  `format`/`path`), `fs` (file IO, with `path`), `net` (HTTP/socket/ws,
  with `url`/`host`), `sql`, `closed` (closed channels), `budget`
  (ECKO_AI_MAX_CALLS, with `calls`/`max`), plus the existing `cancelled`.
  Programmer mistakes (wrong types/arity) stay prose-string panics;
  `get(e, "kind")` is now TOTAL (null on any non-hit, never an error), so
  one `match get(e, "kind")` handles every caught value; `Ok`/`Err` are
  reframed as plain data-modeling types (nothing in the stdlib returns
  them). Messages are unchanged, so uncaught errors render exactly as
  before; `catch (e)` on migrated failures binds a map instead of a
  string (breaking for `contains(e, ...)` dispatch - which is the point).
- **Resolver v2 + pre-run gate**: `ecko check` grew scope-aware
  unused-variable/param/function/import findings (replacing the old
  text-count heuristics), builtin-shadowing warnings (only when the file
  calls that builtin), top-level use-before-definition detection,
  Damerau-style did-you-mean (transpositions now match), and an
  unwrapped-credential rule (os.env("*_KEY"-shaped) not wrapped in
  secret()). Rules are severity-classed; running a file now lints it
  first and refuses to start on definite will-crash findings (undefined
  names, impossible arities, use-before-definition) - warnings never
  block. Zero findings across examples/ and craaft-ecko (whose CRAAFT_KEY
  read is now secret()-wrapped, closing the loop on the credential rule).
- **Copy-on-write collections** (performance; no semantic change):
  lists/maps/structs/modules inside `Value` are Arc-backed - cloning is an
  Arc bump, mutation copies only when shared. Passing large collections to
  functions and spawned tasks stops deep-copying (the share-nothing spawn
  tax becomes O(1) until written); nested-path writes copy just their
  spine; equality got a pointer fast path. Value semantics are guarded by
  tests/cow.rs aliasing traps on both engines.
- **In-place `x = x + rhs`** (both engines, call-free rhs): list and string
  append now take the value out of its binding and extend it in place, so
  accumulation shapes the `x = push(x, ...)` peephole can't see (`xs = xs +
  [i]`, `s = s + "chunk"`) run in O(n) total instead of O(n^2) - 20k list
  appends went from 8.3s to instant. A type error restores the untouched
  value.
- **std.term keyboard input** (unix): `term.raw_mode(on)` (termios raw
  mode, restored on exit), `term.read_key()` /
  `term.read_key(timeout: ms)` (one keypress as a friendly name - "a",
  "enter", "up", "ctrl-c", "alt-x", "f5", ...; null on EOF/timeout), and
  `term.poll()` (input ready, non-blocking). The escape-sequence parser
  is pure and unit-tested; interactive demo in `demos/keys.ecko`.
- **Secrets primitive**: `secret(v)` wraps credentials; the wrapper
  renders as `[secret]` through every stringifying sink (print,
  interpolation, logs, traces, errors, JSON encoding). `reveal(s)` is the
  only, greppable door out; `is_secret(v)` tests. `"x" + secret` is a
  type error pointing at `reveal`. Design: docs/design/secrets-design.md.

### Fixed

- **Mock tool loop no longer swallows tool errors**: a failing tool in
  `ai ... using [tools]` without a provider now throws (catchable, with
  structured kinds preserved) instead of quietly becoming the string
  "tool error: ...". Live mode still feeds error strings back to the
  model - there the model is the recovery mechanism; in mock, the
  developer is.

### Breaking: os.args() is the program's own argv

- `os.args()` no longer returns the raw process argv (interpreter binary +
  script path + args); it returns only the arguments after the script, and
  is empty in the REPL/embedders. The new `os.script()` returns the running
  file's path (null when not running a file). Migration: delete `[2..]`
  slices.

### Breaking: silent-failure semantics hardening (pre-1.0)

- **Arity is enforced at the call site.** Missing required arguments and
  extra arguments are errors (`'f' expects 2 argument(s), got 3`); named
  calls that skip a required parameter error with
  `'f' is missing required argument 'x'`. Previously missing params bound
  `null` and extras were silently dropped.
- **`fn` bindings are immutable**, like `let`. Bare reassignment errors;
  explicit `let` redeclaration remains legal.
- **Builtins are shadowable, not assignable.** `sum = 0` declares a new
  binding in the current scope; inside a function it declares a *local* -
  the old behavior permanently replaced the global builtin (e.g.
  `len = fn(x) 99` used to clobber `len` for the whole program).
- **Collection access is strict.** `xs[i]` out of bounds, `m.key` /
  `m["key"]` on a missing key, and missing struct fields are now errors
  (previously all returned `null` and propagated). `get(collection, key)`
  is the blessed nullable lookup; slices remain clamped.
- **New lint: `implicit-global-write`.** Bare assignment from inside a
  function to a top-level binding is legal (rule 1) but now surfaced by
  `ecko check` / the LSP.
- **Float equality is exact.** The epsilon-fuzzy `==` (non-transitive) is
  replaced by IEEE equality; the new `approx(a, b, eps = 1e-9)` builtin is
  the blessed tolerance comparison.


> Nothing past **v0.7** has a release tag yet (`Cargo.toml` is still `0.7.0`), so
> everything below ships unreleased. Subsections map to the roadmap phases that
> delivered them (`docs/roadmap/platform-roadmap.md`,
> `docs/roadmap/stdlib-roadmap.md`).

### Bytecode VM - Phase 0 (skeleton + go/no-go: GO)

- First rung of the P4.2 bytecode-VM milestone: a bytecode compiler + stack VM
  (`src/vm.rs`, internal `run_via_vm`) that compiles a minimal subset -
  arithmetic, comparisons, `if`/`else`, globals, `fn`/call/recursion, native
  builtin calls - and runs it on a value stack with call frames instead of the
  tree-walker's per-call `Arc<Mutex<Environment>>`. **Result: a naive VM (no
  specialization yet) runs `fib(30)` 5.64x faster than the tree-walker** (0.246s
  vs 1.386s), clearing the milestone's go/no-go kill switch. The tree-walker
  remains the shipped engine; the VM grows behind `run_via_vm`, phase by phase,
  until the Phase 4 cutover. `tests/vm.rs` (10), including byte-parity with the
  tree-walker on `fib`. Reuses the `Value` enum and `apply_binop` unchanged.

### `std.rag` - first-class retrieval-augmented generation

- New `std.rag` module turns Ecko's existing AI primitives (`embed`, `cosine`,
  the `ai` keyword) into a cohesive RAG pipeline: `rag.chunk` (split a document
  into overlapping word passages), `rag.index` (embed a corpus into an explicit
  index value - no hidden global), `rag.retrieve` (top-k passages by **hybrid**
  dense-cosine + lexical word-overlap scoring, i.e. built-in reranking), and
  `rag.answer` - **RAG in one line**: retrieve context, ground an `ai` call in
  it, return the answer. Fully deterministic and offline in mock mode (mock
  embeddings + mock `ai`), so it runs with no API key; the hybrid lexical signal
  keeps retrieval sensible offline and sharpens it with a real embedding model.
  No new dependencies - it is pure orchestration over primitives Ecko already
  had. `tests/rag.rs` (9) + `examples/rag_module.ecko`.

### `ecko lsp` - Language Server (complete)

- New `ecko lsp` subcommand: a Language Server Protocol server (built on
  `tower-lsp`) shipped **inside the one binary** - no separate install, works in
  any LSP editor (VS Code, Neovim, Helix, Zed). Adds one dependency
  (`tower-lsp`, over the already-vendored tokio), still one binary.
- **Phase 1 - live diagnostics as you type**: lexer/parser errors as `Error`s
  and every `ecko::analysis::lint` finding (dead code, unused imports/vars,
  unreachable code) as a `Warning`, mapped to proper 0-based LSP ranges.
- **Phase 2 - IDE features**: **completion** (keywords, builtins, std module
  names, and the document's own top-level symbols; after `<module>.` it offers
  that std module's members), **hover** (a user function's signature, or a
  builtin/module label), **go-to-definition** (jumps to a user symbol's
  declaration), and **document symbols** (the outline of top-level
  functions/types/constants/variables). All four are pure, unit-tested functions
  of `(source, position)` over a new reusable `analysis::symbols` pass; the
  server layer is thin glue with a document store. The static keyword/builtin/
  module sets are ported from the VS Code extension's stopgap provider.
- **Phase 3 - VS Code client**: the bundled VS Code extension now launches
  `ecko lsp` as a language client (`vscode-languageclient`) instead of shipping a
  static, non-scope-aware completion provider. It finds the binary on `PATH` or
  via the `ecko.server.path` setting, and degrades gracefully (syntax
  highlighting + snippets keep working, with a one-time notice) when the binary
  is absent. Extension bumped to 0.5.0.

### Richer diagnostics - source snippets, "opened here", stack traces

- Error messages leveled up from single-line `(line N, col M)` to Rust/Elm-class
  diagnostics. A new `ecko::diagnostics` module renders a source snippet with a
  caret under the exact column; unbalanced blocks point back to where they were
 **opened** ("opened here"); an undefined variable suggests the nearest
  in-scope name ("did you mean 'greeting'?", by edit distance); and runtime
  errors print the **source line for each call-stack frame**. Carets align
  correctly under tabs. This is the shared error-type foundation the future
  `ecko lsp` surfaces. The parser keeps its `String` error contract - the CLI
  adapts messages into structured diagnostics at the boundary - so embedders and
  the one-line `eval_file` are unchanged; the new `eval_file_pretty` drives the
  rich CLI rendering.

### `ecko lint` - static analysis linter

- New `ecko lint <file>` command, built on the same `ecko::analysis` module as
  `ecko explain`. Fully deterministic and offline; four false-positive-free
  checks: **never-used functions**, **unused imports** (whole-module and
  per-item for selective imports), **unused variables** (`let`/`const`/bare
  assignment; `_`-prefixed names are exempt), and **unreachable code** (a
  statement after an unconditional `return`/`break`/`continue`, found by a
  complete block walk - a conditional `return` inside an `if` does not count).
  Reference checks count whole-word over the raw source, so they only ever
  *under*-report - a genuinely live name is never flagged. Output is the
  grep-friendly `file:line: rule: message`; exit code is 0 (clean), 1 (issues),
  or 2 (read/parse error) for CI. Shadowing and scoped unused *locals* are
  deferred to the bytecode VM's resolver pass.

### `ecko explain` - AI-aware code insights

- New `ecko explain <file>` command: the AI-native language explains your code.
  Two layers. First, **deterministic, offline static analysis** (a new
  `ecko::analysis` module, reusable by a future `ecko lint`): the program's
  structure - top-level functions and their arities, imported modules, whether
  it uses `ai` - plus a conservative **dead-code signal** (functions defined but
  never referenced; counted over the raw source so a use inside a string
  interpolation still counts, and it never falsely flags a live function).
  Second, when a provider is configured, a **plain-language AI explanation
  grounded in those static facts** (not just the raw source). Runs fully offline
  for the static half. From the AI-aware-compiler flagship idea.

### `std.cli`

- New module for declarative command-line parsing (pairs with `std.term` for
  building CLI tools). A CLI is a plain spec map; `cli.parse(spec, argv)` returns
  `{ options, args, rest, command, help }` and `cli.help(spec)` renders usage
  text. Supports long/short options (`--name v`, `--name=v`, `-n v`, `-nv`),
  `flag: true` booleans, a value type inferred from each option's `default`
  (Int/Float/String), the `--` options terminator, positional args + `rest`,
  subcommands (via a `commands` list), required-field validation, and
  `--help`/`-h` (which short-circuits validation). Bad input raises a first-class
  catchable error. Parsing is pure, so it is fully testable with an explicit
  args list.

### `std.term`

- New module for building CLIs and TUIs: styled output (named colors + `bright_*`,
  `rgb` truecolor, `color` 256, attributes, and a combined `style(...)`), cursor
  movement (`goto`/`up`/`down`/`left`/`right`/`save`/`restore`/`hide`/`show`),
  screen control (`clear`/`clear_line`/`clear_down`/`alt_screen`), and info +
  utilities (`size()` -> `{rows, cols}`, `is_tty()`, `strip()`, visible `width()`,
  and OSC-8 `link()`). Every function returns a string, so a whole frame can be
  built and printed once (flicker-free). Escape codes are emitted only when
  interactive - styling degrades to plain text and control to `""` when stdout
  is not a TTY or `NO_COLOR` is set, and `CLICOLOR_FORCE` forces them on - so the
  same program is colorful in a terminal and clean in a pipe. New dependency:
  `terminal_size` (for `size()`).

### Internal: `spawn_task` helper for governed background tasks

- The four permit-governed spawn sites (`std.bg`'s `spawn`/`after`/`every` and
  the `async fn` task spawn) hand-rolled the same "acquire a task permit, run
  with `catch_unwind`, release, complete the handle" boilerplate. Extracted a
  single `spawn_task(handle, body)` helper (plus a shared `panic_message`) that
  owns it, so the permit is always released - even on early return or panic -
  and a new spawn site can't forget it. Behavior unchanged; from code-review
  finding #3. Guarded by `tests/task_permit_release.rs`.

### Friendlier, on-brand user-facing text

- Reworded the CLI, `--help`, and REPL in Ecko's voice (lowercase `ecko`, warmer
  banner/prompts, `:help`/`:exit` colon commands accepted alongside the bare
  words) and gave command output a human tone ("Added mathx - vendored and
  locked.").
- Swept the runtime, parser, and lexer error messages to a consistent, friendly
  voice (contractions, `needs` instead of `expects`, `couldn't` instead of
  `failed to`), and hand-wrote the highest-traffic errors to guide toward a fix -
  e.g. `Can't add {} and {} - try converting one with int(), float(), string(),
  or decimal().`, `Can't divide by zero.`, `Undefined variable 'x' - is it
  defined, in scope, and spelled right?`, and `This string is missing its closing
  quote`. No error semantics changed.

### Documented: empty-list / absent-value convention

- The language spec now states the rule (no behavior change): retrieval
  (`first`/`last`/`get`/`find`/indexing) returns `null` when absent; reductions
  that need an element (`min`/`max`) error on empty; identity-value operations
  (`sum`, `filter`, `frequencies`, ...) return their neutral value. Enforced by
  `tests/empty_values.rs`. Resolves code-review finding #15 - the flagged
  `min`/`max` vs `first`/`last` "inconsistency" is two different kinds of
  operation, each internally consistent.

### Performance: `unique` is O(n) for scalars

- `unique` was O(n^2) (a `values_equal` scan per element). It now uses a
  set-backed O(n) fast path when every element is a cleanly hashable scalar
  (Int/String/Bool/Null), falling back to the original scan only for floats and
  composite values (lists, maps, structs) where `values_equal` semantics matter.
  Behavior is unchanged. From code-review finding #19.

### `sort` / `sort_by` are deterministic (behavior change)

- `sort` and `sort_by` now raise an error when elements (or keys) are not
  mutually comparable - mixed types like `[1, "a"]`, unorderable values such as
  maps, or NaN - instead of silently treating them as equal, which left the
  order nondeterministic. Homogeneous lists (including mixed Int/Float, which
  are comparable) are unchanged. From code-review finding #5.

### Internal: stdlib modularization

- Split the ~1,886-line `import_std_module` monolith in `evaluator.rs` into one
  `src/interpreter/stdlib/<module>.rs` file per `std.*` module, each exposing a
  `register(module)` that `import_std_module` now dispatches to. Behavior is
  unchanged (same 502 tests, corpus gate green); `evaluator.rs` shrank by ~1,840
  lines. Addresses the top finding of the code review.

### `std.string`

- New module gathering the full string-manipulation toolkit (the common ops
  stay as globals; import `std.string` for the fuller set). Every operation is
  character-indexed, so it is UTF-8-correct. Surface: case (`upper`, `lower`,
  `capitalize`, `title`), trim (`trim`, `trim_start`, `trim_end`,
  `trim_prefix`, `trim_suffix`), `pad_start` / `pad_end` (cycling fill),
  `substring` / `char_at` (negative indices), search (`contains`,
  `starts_with`, `ends_with`, `index_of`, `last_index_of`, `count`), `replace`
  / `replace_first`, `split` / `split_whitespace` / `lines` / `join` / `chars`
  / `reverse`, `len` / `is_empty` / `is_blank` / `repeat`, and `from` (convert
  any value). Note: importing binds `string`, shadowing the global `string()`
  constructor in scope - use `string.from(x)` instead.

### Money-safe decimals (P3.3)

- New first-class **`decimal`** type: an exact base-10 number (~28 significant
  digits, backed by `rust_decimal`) for money and any value where `0.1 + 0.2`
  must equal exactly `0.3`. Write a literal with a trailing `m` (`19.99m`, `3m`)
  or build one with `decimal(int|string)`.
- Arithmetic is checked (overflow and divide-by-zero are runtime errors, like
  `int`). `decimal` mixes with `int` and stays `decimal`; mixing with `float` is
  a hard error, and `decimal(aFloat)` is rejected - a float has already lost
  precision, so pass a string. `+`/`-`/`*` preserve scale (`19.99m + 0.01m` is
  `20.00`); `/`/`%` normalize (`decimal(10) / 4` is `2.5`). `fmt` round-trips the
  `m` suffix; `json_encode` emits a decimal as a plain JSON number. `int` was
  already checked-overflow, so bignum was not needed for money-safety.

### `std.net`

- New module for DNS and raw TCP, below the HTTP/WebSocket layer:
  `net.lookup(host)` resolves a hostname to a list of IP strings; `net.connect(
  host, port)` opens a TCP connection (30s timeout) returning a handle, then
  `net.send(c, data)` / `net.recv(c, n)` (one read up to n bytes, default 64
  KiB; `null` on close) / `net.close(c)`. Connections are opaque handles behind
  their own mutex. Std-only, no new dependency.

### WebSockets (`std.ws`)

- New module: a WebSocket **client** over `ws://` and `wss://` -
  `ws.connect(url)` returns a connection handle, then `ws.send(c, text)` /
  `ws.recv(c)` (blocks; `null` on close) / `ws.close(c)`. Connections are opaque
  integer handles in a registry (like `std.sql`), each behind its own mutex.
  Crate `tungstenite` (native-tls).
- **Server-side**: `http.serve` accepts an `on_ws: |c| { ... }` callback. An
  incoming WebSocket upgrade is handshaked (via `tungstenite::derive_accept_key`
  + tiny_http's connection upgrade) and its handler runs on a dedicated thread,
  using the same `ws.send`/`ws.recv`/`ws.close` on the handle. Without `on_ws`,
  an upgrade is refused with 501.

### HTTPS/TLS for `http.serve`

- `http.serve` accepts `cert:` and `key:` (PEM file paths) to serve over TLS:
  `http.serve(8443, handler, cert: "cert.pem", key: "key.pem")`. Enables
  `tiny_http`'s `ssl-rustls` feature (adds rustls 0.20 + rustls-pemfile +
  zeroize). Both options are required together; without them it stays HTTP.

### HTTP client response streaming

- `http.get`/`post`/etc. accept `stream: true`, returning a response whose
  `body` is a stream of text chunks (`for chunk in resp.body`) drained in the
  background instead of a buffered string. Using `resp.body` as a value still
  blocks for the full text. Reuses the existing stream machinery; no new deps.

### `std.math`

- New module: constants (`pi`, `e`, `tau`, `inf`, `nan`) and float-domain
  functions - trig (`sin`/`cos`/`tan`/`asin`/`acos`/`atan`/`atan2`), `exp`/`ln`/
  `log2`/`log10`/`log`, `sqrt`/`cbrt`/`pow`/`hypot`, `sign`, `clamp`, and
  `factorial` (checked overflow). The Int-aware basics (`abs`/`floor`/`ceil`/
  `round`/`min`/`max`) stay global. No new dependency.

### Stdlib housekeeping: `std.csv`, `std.json` file I/O

- Renamed `std.data` to **`std.csv`** and dropped the redundant `_csv` suffix:
  `csv.parse` / `csv.stringify` / `csv.read` / `csv.write`, matching the
  `std.toml` / `std.yaml` shape. Breaking: `import std.data` no longer resolves.
- Moved `data.load_json` into **`std.json`** as `json.read(path)`, and added
  `json.write(path, value)` for symmetry with `encode`/`decode` and the config
  modules.
- Moved the global `uuid()` into a **`std.uuid`** module with `uuid.v4()`
  (random) and `uuid.v7()` (time-ordered, a better database key). Breaking: the
  bare `uuid()` global is gone; `import std.uuid` and call `uuid.v4()`.

### Packages - vendored imports + `ecko add`/`install`/`remove`/`pack` (P2.1)

A registry-free dependency story: zips hosted anywhere, vendored under `./vendor/`
and hash-pinned, committed for offline reproducibility.

- **Imports & manifest**: a bare `import name` (not `std.*`, not `"./path"`)
  resolves to `./vendor/<name>/` via its `ecko.json` entrypoint, binding exports
  under `<name>`. `ecko init` scaffolds a manifest; validation enforces
  name↔directory match, a relative in-package entrypoint, and reserved `std`.
- **`ecko add <url|path>`**: fetches a package zip (http or local), verifies and
  unpacks it to `vendor/<name>/`, pins its sha256 in `ecko.lock`, and records the
  dependency in `ecko.json`.
- **`ecko install`**: rebuilds `vendor/` from the lockfile, re-verifying each
  pinned hash (a mismatch is a hard error).
- **`ecko remove <name>`**: deletes the vendored package and prunes it from
  `ecko.json` + `ecko.lock`.
- **`ecko pack [-o <file>]`**: builds a deterministic, self-contained
  distributable zip (manifest + `.ecko` + `vendor/`).
- **Hardening** (untrusted-input surface): zip-slip rejection, zip-bomb/size caps
  (`ECKO_PKG_MAX_BYTES` / `ECKO_PKG_MAX_UNPACKED`), a streamed download cap, and a
  30s fetch timeout (crate `zip`, deflate-only). Capability sandboxing (the
  `capabilities` manifest field) is parsed but not yet enforced - the next phase.

### Config formats - `std.toml` / `std.yaml` (P3.1)

- **`std.toml` / `std.yaml`**: `parse`/`stringify`/`read`/`write` for each,
  routed through serde_json with sorted keys for deterministic output. Crates
  `toml`, `serde_yaml_ng`.

### Async runtime & structured concurrency (P1.1)

Made `async`/`await` real (structured concurrency on threads) - the interim
"async is a no-op" stderr notice is gone.

- **Tasks & `await`**: calling an `async fn` spawns its body on a task thread and
  returns a `Value::Task`; `await` joins it (identity on a non-task). Errors
  propagate at `await` as first-class values.
- **Channels**: `channel`/`send`/`recv`/`close` (unbounded MPMC) with
  `for v in ch` draining until closed.
- **Cooperative cancellation**: `cancel(task)` sets a flag the tree-walker checks
  at loop/call checkpoints; awaiting a cancelled task raises `{ kind: "cancelled" }`.
- **Streaming with live SSE**: `ai "..." -> stream` + `for chunk in s`; untyped
  live streams deliver provider tokens as they arrive over real SSE for
  OpenAI/Anthropic/Ollama (`llm::chat_stream`).
- **Running cap**: `ECKO_MAX_TASKS` (default 256) bounds concurrently running
  tasks; a task parked on `await` releases its permit and reclaims one on resume.

### First-class errors & AI-moat hardening (P0)

- **First-class error values**: `error(v)` throws *any* value; `catch (e)` binds
  it. The `{ kind, message }` map idiom lets handlers branch and re-throw;
  builtin/string errors are still caught as their message (backward compatible).
- **Native message-array sessions**: `ai "..." with s` sends prior turns as a
  role-separated message array, not a flattened transcript.
- **`cost` / `tokens` truthfulness**: `cost(in, out, in_per_1m, out_per_1m)`
  prices any model absent from the built-in table; `tokens` is OpenAI-exact
  (cl100k_base) and approximate elsewhere.
- **Tool-loop bounds**: a round's tool threads are bounded by `ECKO_MAX_PARALLEL`;
  the tool-loop timeout now signals cooperative cancellation instead of purely
  abandoning the thread.

### Hardened `http.serve` (P1.3)

- **Per-request isolation**: a panicking handler becomes a 500 (worker survives,
  via `catch_unwind`); a runaway handler is cut off by a per-request timeout
  (`ECKO_HTTP_REQUEST_TIMEOUT_MS`) and returns 503.
- **Graceful shutdown**: Ctrl-C (SIGINT) or a new `http.stop()` drains in-flight
  requests, then `serve` returns cleanly; a second Ctrl-C forces exit.

### Standard-library buildout - Tiers 1-3

Tier 1 (credibility):
- **`std.time`**: `now`/`now_iso`/`monotonic`/`format`/`parse` (Int millis, UTC).
- **`std.re`**: `test`/`find`/`find_all`/`captures`/`replace`/`split`
  (pattern-first), clear errors on bad patterns.
- **`std.os` / `std.fs`**: os `env`/`env_or`/`set_env`/`args`/`exit`/`exec`
  (→ `{code, stdout, stderr}`); fs `exists`/`is_dir`/`is_file`/`list_dir`/
  `mkdir`/`remove`/`read`/`write`/`join`.
- **`std.sql`**: embedded SQLite - `open`/`exec`/`query`/`query_one`/`close`,
  `?` parameter binding (crate `rusqlite`, bundled).
- **`std.web` router**: `web.router([...])` over `http.serve` -
  `get`/`post`/`put`/`delete`/`patch`, `:param` extraction, 404 fallthrough.

Tier 2 (productivity):
- **`std.random` / `uuid` / `std.hash` / `std.encoding`**: `uuid()`; random
  `seed`/`int`/`float`/`choice`/`shuffle`; `sha256`/`hmac_sha256`; base64/hex/url
  encode + decode.
- **CSV** (`std.data`): `parse_csv`/`to_csv` (sorted headers)/`read_csv`/`write_csv`.
- **Collection helpers** (globals): `group_by`/`frequencies`/`chunk`/`window`/
  `partition`/`find`/`count`.
- **`std.fmt`**: `format` (raw-string templates), `pad_left`/`pad_right`/
  `truncate`/`repeat`.
- **`std.log`**: `debug`/`info`/`warn`/`error` with sorted field maps; `ECKO_LOG`
  verbosity.

Tier 3 (AI moat):
- **Tool / function calling**: `@tool("...")` + `ai[T] "..." using [tools]` - a
  live multi-round loop across OpenAI/Anthropic/Ollama that invokes Ecko
  functions and feeds results back, coercing the final answer to `T`. A round's
  tool calls run concurrently; `ECKO_AI_MAX_TOOL_ROUNDS` (default 8) bounds it.
- **Embeddings**: `embed`/`embed_all`/`cosine` globals; deterministic mock
  vectors offline.
- **Token counting & cost**: `tokens` (cl100k_base) and `cost` (per-model table).
- **Retry & memory**: `retry(n, f)` with exponential backoff
  (`ECKO_RETRY_BASE_MS`); `session()` + `ai "..." with s` for multi-turn calls.
- Keyword tokens (`type`, `match`, …) accepted as field names after `.`.

### Interpreter speed - "Fast Tier" (branch `milestones/v0.8`)

- **Faster variable lookups** (v0.8): the environment now hashes with FxHash
  (rustc's hasher) instead of the default SipHash - variable names are
  short, trusted source tokens, so the crypto hasher was pure overhead.
  Measured (release, best-of-5): fib 84→74ms, loops 1069→908ms,
  matching 407→339ms, strings 38→32ms (~12-17% on compute-bound work).
- **`parking_lot::Mutex`** for the interpreter's locks: perf-neutral
  (`std::Mutex` is already futex-fast uncontended on Linux) but adopted for
  robustness - worker-thread panics (`pmap`/`http.serve`) no longer poison
  locks, and the poison-recovery boilerplate is gone.
- Profiling note: scope-allocation elision and loop-scope reuse were tried
  and reverted (zero measured benefit). The remaining gap to CPython needs
  the slot-frame rewrite (per-access lock removal), not micro-tweaks - see
  fast-tier-design.md.

- **Linear-update optimization** (v0.8 Stage 1): `x = f(x, …)` now moves `x`
  out of its environment slot instead of deep-cloning it, so accumulators
  (`x = push(x, item)`, `m = insert(m, k, v)`) run in **O(n) instead of
  O(n²)**. Measured: a 20k-element `push` loop went 8.77s → 0.01s (~900x).
  Value semantics are preserved exactly - the move fires only when the
  variable is mentioned once, so `x = x + x` and cross-variable assignments
  still copy. See fast-tier-design.md (Option A approved).

- **`cell` - thread-safe shared state** (v0.8 Stage 2): `cell(v)`,
  `cell_get`, `cell_set`, `cell_update(c, fn)` (atomic read-modify-write).
  The explicit escape hatch for sharing mutable state across `pmap`/
  `http.serve` workers, ahead of Stage 3's share-nothing model. Verified
  with a concurrent HTTP request counter (no lost updates).

## v0.7 - "Fast & Frugal" (branch `milestones/v0.7`)

- **Majority voting**: `ai[T] n "prompt"` runs n independent samples (1-25)
  and returns the majority-coerced value; bypasses the cache, counts
  against the budget, incompatible with `-> stream`.

- **Prompt caching**: content-addressed `ai` calls via `ECKO_AI_CACHE=<dir>`
  or `--cache`. Hits replay through normal coercion, are budget-free, and
  trace as `cached: true`. Collision-safe (entries verify the full prompt).
- **Persistent `py` worker**: one long-lived Python process (JSON-lines
  over stdio) replaces subprocess-per-call - 500 calls in 35ms (≈130x+).
  Interpreter state persists across calls; Python exceptions are catchable
  without killing the worker; `print` goes to stderr. Crash → respawn.
- **`sleep(seconds)`** builtin.
- (in progress: interpreter speed pass)

- **Error-provenance and streaming hardening** (external review): failed
  streaming `ai` calls now surface their error at first use instead of
  silently becoming null, and streams resolve in operators/conditions;
  streaming calls get the same coercion-retry + tracing as sync calls;
  errors crossing the builtin-callback boundary (map/filter/pmap) keep
  their line and call stack; contract errors preserve span/stack; retry
  counts unified (initial + ECKO_AI_MAX_RETRIES everywhere); coercion
  failures now reach the retry prompt even inside contract retries;
  string-contract and std.llm.chat calls are traced and budget-counted;
  std.llm.chat model override no longer mutates global env (pmap race);
  pmap cancels queued work after the first error and reports panic
  messages; interpolation/template errors point at real source lines.

## v0.6 - "Web, Data & Templates" (branch `milestones/v0.6`, PRs #4-#5)

- **Triple-quoted strings**: `"""..."""` multi-line literals with dedent
  (leading newline stripped, closing indentation stripped, common indent
  removed, relative indent preserved). REPL supports multi-line entry.
- **Template engine**: `template name(params) = """..."""` - functions whose
  bodies are template strings with `{expr}` interpolation and
  `{for}`/`{if}`/`{else}` control flow. Directives alone on a line vanish
  from output. Templates are first-class values and feed `ai` directly.
- **Named arguments**: `f(x, tone: "formal")` on every function - positional
  bind first, named by parameter name, defaults fill the rest.
- **`ecko fmt`**: canonical formatter (`ecko fmt <files>`, `--check` for CI).
  Comments preserved; string/template literals byte-for-byte; idempotent;
  verified to never change program behavior.
- **Licensing**: proprietary (LICENSE added; README, extension manifest,
  and Cargo aligned; `publish = false`).
- **VS Code grammar 0.2.0**: `template` keyword, triple-quoted strings,
  `{end}` directives, `let`/`export`/`finally`.
- **Full HTTP client**: `http.get/post/put/patch/delete(url, headers: {...},
  json: {...}, body: "...", timeout: 30)` → `{ status, headers, body }`.
- **HTTP server**: `http.serve(port, handler)` - request map in
  (`method`/`path`/`params`/`headers`/`body`/`json`), response helpers
  (`http.html/json/text/not_found/response`), bounded worker pool
  (`ECKO_HTTP_WORKERS`), handler errors become 500s.
- **`escape_html`** builtin.
- **Raw strings**: `r"..."` / `r"""..."""` - no interpolation, no escapes.
- **Vector store**: `std.db` - `add`/`search(query, limit: n)`/`remove`/
  `count`/`clear`/`save`/`load`; provider embeddings via
  `ECKO_AI_EMBED_MODEL`, deterministic mock embeddings offline.
- **`{input expr}` template directive**: injection-safe prompt context -
  values are delimited and embedded closing tags neutralized.
- **Schema-valid typed mocks**: `ai[T]` without an API key returns
  deterministic values through the real coercion path (Int→42, enums→first
  variant, structs→mock fields) - typed pipelines fully testable offline.
- **`ECKO_AI_MAX_CALLS`**: hard per-process budget on `ai` calls.
- Fixed: `\}` in strings and templates now produces a literal `}` (matching
  `\{`); literal braces no longer leave stray backslashes.

## v0.5 - "Verified & Observable" (branch `milestones/v0.5`, PR #3)

- **Runtime diagnostics**: errors carry `line:col` spans and a call stack.
- **AI-verified contracts**: natural-language `@requires`/`@ensures` checked
  by the LLM; self-correcting retry with failure feedback for `ai`-bodied
  functions (`ECKO_AI_MAX_RETRIES`).
- **Tracing**: every `ai` call logged with call site, provider/model,
  latency, retries, token usage, and prompt content hash (`--trace`,
  `ECKO_TRACE` for stderr one-liners or JSONL files); process-wide across
  `pmap` workers and streaming threads; drainable buffer for tooling.

## v0.4 - "At Scale" (branch `milestone/v0.4`, PR #2)

- **Foundations rework**: real control flow (`return`/`break`/`continue`/
  `loop`), shared-scope closures, evaluated match guards, scoped pattern
  bindings, short-circuit `and`/`or`, enforced `let`/`const`/`mut`,
  field/index assignment, ranges, `|x|` lambdas, statement termination
  rules, UTF-8 lexer with spans, `line:col` parse errors, ~60 builtins,
  `Result`/`Option` built in, full-language integration suite, CI.
- **User modules**: `import "./file"` with `export { ... }` and
  circular-import detection.
- **Provider independence**: `ECKO_AI_PROVIDER` = openai | anthropic |
  ollama; shared runtime; timeouts and retries; mock mode without a key.
- **Parallel + streaming AI**: bounded `pmap` batching (`ECKO_MAX_PARALLEL`),
  `-> stream`.

## v0.3 - "Data & Verification"

- Boolean `@requires`/`@ensures` contracts; `json<T>` generic types with
  schema generation and coercion; stdlib structured-output types;
  `ecko test --generate`; `py()` Python FFI.

## v0.2 - "Scripting with Superpowers"

- Core language, `ai` keyword with typed output, provider CLI flags,
  `ecko fix`.
