What “clean locally” is allowed to mean

How a local platform emulator reproduces leases, retries and denied egress, and says where its verdict stops at the moment a check comes back clean.

November 20265 min readPlatformsVerificationInterfaces

Short curving strokes of sienna and black thicken in a loose oval at left, with long bone strokes and one dusty blue mark.
On this page
  1. What it reproduces
  2. What it leaves out
  3. Where the two gates disagree
  4. When the developer is an agent

By August 2026 I had built a local development environment for Ferrite, my application platform, in the same family as Wrangler with Miniflare, the Firebase emulators and LocalStack. The CLI scaffolds an application, ferrite dev runs it under hot reload against a single-process runtime on localhost, and ferrite check runs the code gate, spec validation and cargo check, all without a checkout of the platform’s core or a fleet of machines to run it on.

Everything in this family shares a hazard. An application that works on your laptop produces a strong and reasonable belief that it works, and the better the emulator, the stronger the belief. So fidelity is only half of the design. The other half is how the tool tells you where its fidelity stops, in the places you are actually looking when you decide to ship.

What it reproduces

The local runtime is a cleanroom implementation of the platform’s published contracts, not a cut-down copy of production, and it models six planes behind a localhost gateway, among them a store, a queue, a log, topics and blobs. Modeling them carefully matters because applications get written against the details. A leased queue message has a visibility timeout and an attempt budget, 30 seconds and five attempts by default, and when the budget is spent the message goes to a dead-letter queue. A commit envelope holds between 1 and 64 keys. A TTL is an absolute instant. A convenient fake gets things like that slightly wrong, and then you have written your application against the fake.

Single-node doesn’t have to mean happy-path. --instances N runs several copies of an application against one set of planes, and --chaos SECS kills them with SIGKILL at jittered intervals. Work they held on lease goes back on the queue with its budget intact, which can expose double-counting in an application that isn’t idempotent, on your laptop, while you watch. Restarts behave the same way: leases are in memory by contract, so after a reload a leased message comes back as available with its budget intact, and the rest of the local state persists under .ferrite/state/. Time gets similar treatment. The default is the real wall clock, but a virtual clock can be advanced on request, so a TTL or a delay can fire without anyone waiting for it. The runtime holds itself to this as well: one of its tests pushes 60 jobs through 25 rounds of three workers aborted at random points, and demands the exact number of admissions and a drained queue at the end, with nothing pending and nothing dead-lettered.

Outbound network access is denied by default. An application declares the destinations it may reach, requests are matched against them structurally, by scheme, host, port and path segments, and redirects are disabled. Resolved addresses are validated and then pinned, so a name can’t pass the check and connect somewhere else, and loopback, private and link-local addresses are refused. A call you forgot to declare fails on the laptop, which is the cheapest place for it to fail.

What it leaves out

The local runtime deliberately leaves replication, failover, capability enforcement, placement, quotas and billing to the hosted platform. It concentrates on the contracts an application can exercise locally; a test of that implementation does not establish how the platform will enforce authority or recover a fleet. Killing local processes tells you how your application handles redelivery. It tells you nothing about how the platform fails over.

Where the two gates disagree

The line matters most at the code gate. The platform refuses source it won’t build. The CLI carries a local gate that emits the same family of refusal codes, with an offline ferrite explain for reading them, so a refusal reads the same wherever you meet it. But these are two implementations that differ on purpose, and in two known places they disagree in opposite directions. So a passing local check ends like this:

clean locally — local is advisory, and the two tiers are known to disagree in both directions:
  macros: the local gate has no macro allowlist; the platform enforces a reviewed one, so `ferrite_app::tracing::info!`, `json!`, `dbg!`, and any `macro_rules!` you define pass here and fail there as CODE_MACRO_OPAQUE.
  build-time reads: the local gate refuses every `include!`, `include_str!`, and `include_bytes!`; the platform admits one whose target resolves inside the crate.
Run `ferrite check --remote` for the platform's own verdict; `ferrite deploy` runs it for you before it submits a build.

The placement is the important part. Printed under a failure, the note would be read by someone already looking for a problem. Printed under a pass, it reaches them at the moment they are inclined to stop reading, and the same list travels in the --json report, so an agent reading JSON and a person reading the terminal are told the same thing. By default, ferrite deploy asks the remote gate before submitting a build. --skip-remote-gate bypasses that preflight, but the platform still re-gates the source during the attested build, so the local verdict can’t ship anything on its own. In ferrite dev the gate only warns, because previewing code that wouldn’t pass yet is a deliberate use of the tool.

A known disagreement is one thing and a missing rule is another. A local rule earns its keep by saving a round trip, and the one that refuses modules relocated with #[path] says so in its own comment: it earns its place by having been missing, because the refusal otherwise came from the attested build, and feedback from there costs minutes. What the scanner tries never to do is call source clean because it gave up on reading it. Past its nesting limits it refuses the file outright, even where the compiler would be perfectly happy with it.

The SDK has the same problem one layer down, because the ferrite-app an application compiles against locally is itself a copy. Scaffolded projects vendor it, and at the attested build the platform redirects the dependency, so the local edition never ships. A project can also fall out of step with the CLI, so the CLI embeds a SHA-256 digest of its own edition at build time and ferrite-twin.json records what the project has. A mismatch produces a warning, never a refusal, and a failure inside vendored code comes with a note saying that’s where it is, which spares you from debugging someone else’s code as though it were your own. Drift between the editions is treated as a class of bug with tests of its own, which makes parity something the tests look for and not something the tool promises.

When the developer is an agent

All of this matters more when the reader of the verdict is a coding agent. The MCP server the CLI exposes keeps the distinction in its tool names: local_* tools are advisory, remote_* tools are authoritative, and the remote build verbs don’t block. The skill packaged for agents tells them never to claim a deploy succeeded on build acceptance alone. A person can pick up what “advisory” means from context and habit. An agent has a tool name and a result, so the standing of an answer has to be in both.

A local environment should reproduce the contracts and their failure modes as faithfully as its implementation can manage, then direct deployment decisions to the platform’s own verdict. Where it stops, it should say so at the moment someone is most inclined to believe it, which is when the check comes back clean.

Chris Chabot, November 2026