`moonzero` assembles microservices for MoonBit — config, resilience middleware, service discovery, tracing, and a Prometheus endpoint — in the shape of go-zero. The portable library lives at the root; `discov/` holds the native drivers that need real I/O.

# Working here

- `moon fmt` before anything else. CI runs `moon fmt && git diff --exit-code`, so an unformatted file fails the build on its own.
- `moon check --target all --deny-warn` is the gate. Warnings are errors, and all four backends (wasm, wasm-gc, js, native) must pass. Run it again after the first round of fixes: a package whose sources do not compile hides the diagnostics in its own test files, so errors surface in waves.
- `moon test --target all` at the root; `moon test --target native` inside `discov/`.
- `moon info` regenerates `pkg.generated.mbti`. If that file does not change, your edit is not visible to anyone depending on this package, which usually means the refactor was safe. If it does change, read the diff before committing — that is the public interface moving. `moon info` skips a package pinned to `supported_targets = "native"`, so `discov/` has no tracked interface and its drift is invisible to review. The examples regenerate their own, which is why those are gitignored.
- CI installs the latest moon on every run, so a toolchain that is behind will disagree with it. Upgrade locally rather than pinning.

# Layout

Root is portable and has no async dependency: `config.mbt`, `yaml.mbt`, the resilience middleware (`breaker.mbt` over `window.mbt`, `maxconns.mbt`, `limits.mbt`, `periodlimit.mbt`, `ratelimit.mbt`), `metrics.mbt`, `tracing.mbt`, `jwt.mbt`, `discovery.mbt`, and the zrpc client. `discov/` is `supported_targets = "native"` and carries the drivers that speak to real etcd, consul and redis over sockets, plus the file-backed registry. Tests sit beside their subject as `*_wbtest.mbt` at the root and `*_test.mbt` under `discov/`; `examples/NN-topic/` are runnable one-file demos.

# Things worth knowing

- Middleware is async — `Middleware` is `(async App) -> async App` — but the root package deliberately does not depend on `moonbitlang/async`, so it cannot host an `async test`. End-to-end middleware tests belong in `discov/`, which already imports async and runs native-only. `maxconns_release_test.mbt` is the pattern to copy, and `rest_engine_test.mbt` drives a whole `RestEngine`-assembled app that way.
- `conf.mbt` is the one place config keys are looked up: canonically (lowercase, `_`/`-` dropped), down dotted paths, with `,env=` and `default=`/`options=`/`range=`. Add a field by naming go-zero's key and letting `also=` carry whatever flat spelling moonzero already published — the loaders in `config.mbt`, `rpc.mbt` and `restconf.mbt` all go through it. A constraint violation raises; nothing falls back silently, which is the defect that made a real `etc/*.yaml` load as all-defaults.
- `RestEngine::layers` is the whole chain, and `names()` is what the tests assert against, so a new layer has to arrive as a `Layer` there rather than being wrapped on afterwards. Two of go-zero's eleven `Middlewares` flags install nothing: `Metrics` is go-zero's internal `stat.Metrics` sink, which has no counterpart here (the one metric set is the Prometheus one `Prometheus` installs), and `Gunzip` needs a DEFLATE decoder that neither moonzero nor any dependency has. Both still parse, so a go-zero config round-trips.
- `logx` is a process-wide `Logger` with a mutable level and writer, and `RestEngine::new` points it at `Log.Level`. A test that touches it must restore both — pass an explicit `Logger` instead wherever that is possible.
- Anything holding a resource across a call into user code must release it with `defer`, not with a trailing statement or a `catch` that re-raises. Both skip cancellation, and the compiler's `fragile_catch_all` lint now says so. `max_conns` leaked a permit exactly this way and wedged the limiter shut at 503.
- The tests against real etcd, consul and redis are gated on `MOON_ETCD_TEST`, `MOON_CONSUL_TEST` and `MOON_REDIS_TEST`; without them the suite silently skips those. CI sets them and starts the containers.
- `ConsulHttp` is `pub trait`, which in MoonBit is sealed — only this package can implement it, and the only implementation is the test fake. `discov/`'s `ConsulSocket` is a real network client but does not implement it, because the trait is synchronous and the socket is not. So `ConsulClient` and `ConsulDiscovery` cannot reach a real server today; closing that means making the trait async and implementing it in `discov/`, or moving the client there. `RedisConn` used to be in the same state and is not any more: it is `pub(open)` with an `async fn execute`, `discov/`'s `RedisSocket` implements it, and `RedisSocket::client()` is how production code gets a `RedisClient` on a real server.
- Making `RedisConn` async turned `RedisClient` and `RedisDiscovery` async with it, which the root package cannot test — so the redis white-box tests moved to `discov/`. It also cost `RedisDiscovery::resolver` its live SCAN: `Resolve` is `(String) -> Array[Endpoint]`, synchronous, and the whole zrpc client above it is synchronous too, so the resolver now hands back the set the last `resolve` found, exactly as `discov/`'s `FileRegistry` separates its async `reload` from its synchronous `resolve`. Making `Resolve` async instead would asyncify `LoadBalancedChannel` and every zrpc test at the root; that is the trade that was declined.
- `breaker.mbt` is a port of go-zero's `googleBreaker` on the `Window` in `window.mbt` (← `core/collection/rollingwindow.go`), constants and all. It has no open/closed state: it sheds a fraction of calls computed from the window, so its tests drive it with a fixed `ManualClock` and a fixed `rand` and assert exact decisions. Keep it that way — a test that samples the roll for real is a flaky test.
- The two limiters come in a local and a shared form. `PeriodLimit` and `TokenBucket` are process-local: N replicas admit N quotas, and their doc comments say so. `RedisPeriodLimit` and `RedisTokenLimit` are the ports of go-zero's, running `periodscript.lua` and `tokenscript.lua` verbatim (`period_script` / `token_script`) through `RedisScript`, which `SCRIPT LOAD`s once and then `EVALSHA`s, re-loading on `NOSCRIPT`. `RedisTokenLimit` keeps a local `TokenBucket` as its rescue, as go-zero does; go-zero also flips a `redisAlive` flag and pings in the background so a dead redis is not dialled per request, which is not modelled — every call tries redis and falls back, which admits the same traffic at a higher cost while redis is down.
- The fake redis in `discov/redis_fake_test.mbt` evaluates both scripts against a real keyspace with real expiry, matching on the script source, so an argument-order or script change stops being understood rather than silently passing. The tests that matter drive two limiter instances at one key and assert they share the quota; `MOON_REDIS_TEST` runs the same thing over two sockets to a live redis.
