diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index b4f3fcca6..7d045d89f 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -89,6 +89,7 @@ export default defineConfig({
label: 'Fly',
items: [
{ label: 'Deploy Fly Machines Offline', slug: 'tutorials/local-fly' },
+ { label: 'Reconcile Fly Machines', slug: 'tutorials/fly-machines-reconcile' },
{ label: 'Fly Deploy with Checkpoint Rollback', slug: 'tutorials/fly-deploy-rollback' },
{ label: 'Managed Agents Worker on Sprites', slug: 'tutorials/sprites-managed-agent-worker' },
],
diff --git a/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx b/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
new file mode 100644
index 000000000..d2ac3445e
--- /dev/null
+++ b/docs/src/content/docs/tutorials/fly-machines-reconcile.mdx
@@ -0,0 +1,164 @@
+---
+title: Reconcile Fly Machines
+description: flyApply reconciles typed Machines against the live Fly control plane with no state file — create, no-op re-apply, in-place update, and owned-only prune that leaves foreign machines alone. Runs offline against a persistent mudflaps emulator, or real Fly by dropping one override.
+---
+
+import { Aside } from '@astrojs/starlight/components';
+
+The [`fly-reconcile`](https://github.com/INTENTIUS/chant/tree/main/examples/fly-reconcile)
+example shows chant's differentiator on Fly: a **stateless reconcile**. Fly's
+Machines API has no server-side declarative apply and chant keeps **no state
+file**, so `flyApply` does the reconcile itself — it diffs your typed resources
+against what the platform reports, and prunes only the machines it owns.
+
+Where [`local-fly`](/chant/tutorials/local-fly/) boots and tears down the
+emulator each run, this example points at a **persistent** mudflaps, so you run
+the op repeatedly and watch the reconcile converge.
+
+## The infra
+
+`src/infra.ts` is one app, a persistent volume, and two machines — `web` (which
+mounts the volume) and `worker`:
+
+```ts
+import { App, Machine, Volume, MachineConfig, MachineGuest, MachineMount, Fly } from "@intentius/chant-lexicon-fly";
+
+const app = new App({ name: "fly-reconcile-demo", org_slug: Fly.OrgSlug });
+const data = new Volume({ name: "data", region: "iad", size_gb: 1 });
+const guest = new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 });
+
+const web = new Machine({
+ name: "web",
+ region: "iad",
+ config: new MachineConfig({
+ image: "flyio/hellofly:latest",
+ guest,
+ mounts: [new MachineMount({ volume: "data", path: "/data" })],
+ }),
+});
+const worker = new Machine({ name: "worker", region: "iad", config: new MachineConfig({ image: "flyio/hellofly:latest", guest }) });
+
+export { app, data, web, worker };
+```
+
+The serializer stamps `managed-by: chant` into each machine's `config.metadata`
+on its own — the ownership marker the owned-only prune reads back. `flyApply`
+creates the volume before the machine that mounts it (the dependency is on the
+wire — the volume is POSTed first), and FLY011 checks at build time that the
+mount names a declared volume.
+
+## The op
+
+`ops/fly.op.ts` is `Build → Apply` only — no emulator lifecycle, so state
+persists between runs:
+
+```ts
+import { Op, phase, build } from "@intentius/chant/op";
+import { flyApplyStep } from "@intentius/chant-lexicon-fly";
+
+export default Op({
+ name: "fly",
+ overview: "Reconcile the plan against a running mudflaps (create / update / prune)",
+ taskQueue: "fly",
+ phases: [
+ phase("Build", [build(".", { script: "build:fly" })]),
+ phase("Apply", [flyApplyStep("dist/fly.json", { endpoint: "http://localhost:4280", prune: true })]),
+ ],
+});
+```
+
+## Run it
+
+Boot mudflaps once, then run the op:
+
+```bash
+docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
+
+cd examples/fly-reconcile
+npm install
+
+chant run fly
+```
+
+The first run creates everything:
+
+```
+created: app/fly-reconcile-demo
+created: volume/fly-reconcile-demo/data
+created: machine/fly-reconcile-demo/web
+created: machine/fly-reconcile-demo/worker
+```
+
+
+
+## Watch it reconcile
+
+The point is what re-running does. State lives on the platform, not in a file, so
+`flyApply` reads it back each time.
+
+**Re-apply is a no-op.** Run it again with no source change — nothing is touched:
+
+```
+unchanged: app/fly-reconcile-demo
+noop: volume/fly-reconcile-demo/data
+noop: machine/fly-reconcile-demo/web
+noop: machine/fly-reconcile-demo/worker
+```
+
+**A config change updates in place.** Edit `web`'s `config.image` in
+`src/infra.ts` and re-run — `flyApply` leases the machine, updates it, and waits
+the new instance to `started`. `worker`, unchanged, still no-ops:
+
+```
+updated: machine/fly-reconcile-demo/web
+noop: machine/fly-reconcile-demo/worker
+```
+
+**Removing a machine prunes it.** Delete the `worker` machine from `src/infra.ts`
+and re-run. Prune is on, so the machine chant no longer declares is destroyed —
+it carries the `managed-by: chant` marker:
+
+```
+noop: machine/fly-reconcile-demo/web
+pruned: fly-reconcile-demo/worker
+```
+
+**Foreign machines survive.** Create a machine directly in mudflaps, without the
+ownership marker, then re-run:
+
+```bash
+curl -X POST http://localhost:4280/v1/apps/fly-reconcile-demo/machines \
+ -H 'content-type: application/json' \
+ -d '{"name":"legacy","region":"iad","config":{"image":"flyio/hellofly:latest"}}'
+```
+
+The owned-only prune leaves `legacy` alone — chant deletes only what it owns, so
+a resource created outside chant is never touched. Authority stays with the
+platform; chant reconciles its own slice of it.
+
+Stop the emulator when done:
+
+```bash
+docker rm -f mudflaps
+```
+
+## Run it against real Fly
+
+Drop the endpoint override and give it a token — the same op, no code change:
+
+```ts
+phase("Apply", [flyApplyStep("dist/fly.json", { prune: true })]), // no endpoint
+```
+
+```bash
+export FLY_API_TOKEN=...
+chant run fly
+```
+
+With no `endpoint`, `flyApply` falls through to `FLY_FLAPS_BASE_URL` (or Fly's
+`https://api.machines.dev`) and authenticates with the token. The reconcile is
+the same against real Fly — the emulator is just an endpoint swap.
diff --git a/examples/examples.test.ts b/examples/examples.test.ts
index 97becec9e..abeebc1bf 100644
--- a/examples/examples.test.ts
+++ b/examples/examples.test.ts
@@ -22,6 +22,7 @@ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
import { k8sPlugin } from "@intentius/chant-lexicon-k8s/plugin";
import deployOp from "./getting-started/deploy.op";
import flyDeployOp from "./local-fly/ops/fly.op";
+import flyReconcileOp from "./fly-reconcile/ops/fly.op";
import agentTaskOp from "./sprites-agent-task/ops/agent-task.op";
import guardedTaskOp from "./sprites-agent-task/ops/guarded-task.op";
import managedAgentSessionOp from "./sprites-managed-agent-worker/ops/managed-agent-session.op";
@@ -237,6 +238,56 @@ describe("local-fly deploy Op (#744)", () => {
});
});
+// ── Fly reconcile — fly-reconcile (#868) ─────────────────────────────
+// A multi-machine stack whose op reconciles against a *running* mudflaps (no
+// boot/teardown), so re-runs show create → no-op → update → owned-only prune.
+// Build-validated here (the live reconcile is documented in the tutorial); a
+// separate block compile-validates the Build → Apply op.
+
+describeExample(
+ "fly-reconcile",
+ {
+ lexicon: "fly",
+ serializer: [flySerializer],
+ outputKey: "fly",
+ examplesDir: import.meta.dirname,
+ },
+ {
+ checks: (output) => {
+ const plan = JSON.parse(output) as Record<
+ string,
+ { endpoint: string; method: string; body: Record }
+ >;
+ const reqs = Object.values(plan);
+ const app = reqs.find((r) => r.endpoint === "/v1/apps");
+ expect(app!.body.app_name).toBe("fly-reconcile-demo");
+ // A volume, created before the machine that mounts it.
+ const volume = reqs.find((r) => /\/v1\/apps\/[^/]+\/volumes$/.test(r.endpoint));
+ expect(volume!.body.name).toBe("data");
+ // Two machines, each carrying the managed-by marker the prune reads back.
+ const machines = reqs.filter((r) => /\/v1\/apps\/[^/]+\/machines$/.test(r.endpoint));
+ expect(machines.map((m) => m.body.name).sort()).toEqual(["web", "worker"]);
+ for (const m of machines) expect(m.body.config.metadata["managed-by"]).toBe("chant");
+ // web mounts the volume by name.
+ const web = machines.find((m) => m.body.name === "web");
+ expect(web!.body.config.mounts).toEqual([{ volume: "data", path: "/data" }]);
+ },
+ },
+);
+
+describe("fly-reconcile op (#868)", () => {
+ test("compiles to a Build → Apply reconcile with prune on", () => {
+ const props = (flyReconcileOp as unknown as {
+ props: { name: string; phases: Array<{ name: string; steps: Array<{ fn: string; args?: Record }> }> };
+ }).props;
+ expect(props.name).toBe("fly");
+ expect(props.phases.map((p) => p.name)).toEqual(["Build", "Apply"]);
+ const apply = props.phases.find((p) => p.name === "Apply")!.steps[0];
+ expect(apply.fn).toBe("flyApply");
+ expect(apply.args?.prune).toBe(true);
+ });
+});
+
// ── Sprites agent task — sprites-agent-task (#762) ───────────────────
// Pure activity-sequence Ops (no build, no serialized plan). The live run is
// documented in the example README (against the in-process fake or real
diff --git a/examples/fly-reconcile/.gitignore b/examples/fly-reconcile/.gitignore
new file mode 100644
index 000000000..320c107b3
--- /dev/null
+++ b/examples/fly-reconcile/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+package-lock.json
diff --git a/examples/fly-reconcile/README.md b/examples/fly-reconcile/README.md
new file mode 100644
index 000000000..d68915df4
--- /dev/null
+++ b/examples/fly-reconcile/README.md
@@ -0,0 +1,62 @@
+# fly-reconcile
+
+One Fly **app + a volume + two machines** (`web` mounts the volume), reconciled
+against a **running** mudflaps by `flyApply`. Where [`local-fly`](../local-fly)
+boots and tears down the emulator each run, this example points at a persistent
+mudflaps so you can run it repeatedly and watch the reconcile: create → no-op →
+in-place update → owned-only prune. No `flyctl`, no state file.
+
+| Resource | Serializes to | Applied by |
+|----------|---------------|------------|
+| App + Volume + 2 Machines | flaps create bodies (JSON) | `flyApply` (prune on) |
+
+The op is `Build → Apply` only — it does not manage the emulator, so state
+survives between runs. `flyApply` GET-then-creates each resource, updates changed
+machines in place, and (with `prune: true`) destroys chant-owned machines no
+longer declared. A machine created directly in mudflaps, without the
+`managed-by: chant` marker, is left untouched.
+
+## Run it
+
+Boot mudflaps once, then run the op as many times as you like:
+
+```bash
+docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.1
+
+npm install
+chant run fly # first run: creates app + volume + web + worker
+chant run fly # second run: no-op — nothing changed
+```
+
+Then exercise the reconcile:
+
+- **Update in place.** Change `web`'s `config.image` in `src/infra.ts` and
+ re-run — `flyApply` leases the machine, updates it, and waits `started`.
+- **Owned-only prune.** Delete the `worker` machine and re-run — the prune
+ destroys it, because it carries the `managed-by: chant` marker.
+- **Foreign resources survive.** Create a machine directly in mudflaps
+ (`curl -X POST .../machines`) and re-run — the prune leaves it alone; it has no
+ ownership marker.
+
+Stop the emulator when done:
+
+```bash
+docker rm -f mudflaps
+```
+
+## Real Fly
+
+Drop the endpoint override and give it a token — the same op, no code change:
+
+```ts
+// ops/fly.op.ts
+phase("Apply", [flyApplyStep("dist/fly.json", { prune: true })]), // no endpoint
+```
+
+```bash
+export FLY_API_TOKEN=...
+chant run fly
+```
+
+With no `endpoint`, `flyApply` falls through to `FLY_FLAPS_BASE_URL` (or Fly's
+`https://api.machines.dev`) and sends `Authorization: Bearer $FLY_API_TOKEN`.
diff --git a/examples/fly-reconcile/chant.config.ts b/examples/fly-reconcile/chant.config.ts
new file mode 100644
index 000000000..1d614b925
--- /dev/null
+++ b/examples/fly-reconcile/chant.config.ts
@@ -0,0 +1,6 @@
+import type { ChantConfig } from "@intentius/chant";
+
+// The fly lexicon (loads the flaps applier + mudflaps lifecycle activities:
+// flyApply / flapsUp / flapsDown) plus temporal (the base activities: chantBuild,
+// httpCheck). `chant run fly` resolves each Op step's `fn` against these.
+export default { lexicons: ["fly", "temporal"] } satisfies ChantConfig;
diff --git a/examples/fly-reconcile/ops/fly.op.ts b/examples/fly-reconcile/ops/fly.op.ts
new file mode 100644
index 000000000..ceb6b33e2
--- /dev/null
+++ b/examples/fly-reconcile/ops/fly.op.ts
@@ -0,0 +1,27 @@
+import { Op, phase, build } from "@intentius/chant/op";
+import { flyApplyStep } from "@intentius/chant-lexicon-fly";
+
+/**
+ * Reconcile the app + volume + two machines against a **running** mudflaps —
+ * `chant run fly`. Unlike the local-fly deploy Op, this one does not boot or tear
+ * down the emulator: you start mudflaps once and run this repeatedly, so the
+ * state persists across runs and the reconcile is observable (re-apply no-op,
+ * in-place update, owned-only prune).
+ *
+ * docker run -d --rm -p 4280:4280 --name mudflaps ghcr.io/intentius/mudflaps:0.4.0
+ *
+ * `flyApply` GET-then-creates each resource, updates changed machines in place,
+ * and — with `prune: true` — destroys chant-owned machines no longer declared. A
+ * machine created directly in mudflaps (no `managed-by: chant` marker) is left
+ * untouched. To target real Fly, drop the `endpoint` override and set
+ * `FLY_FLAPS_BASE_URL` / `FLY_API_TOKEN`.
+ */
+export default Op({
+ name: "fly",
+ overview: "Reconcile the plan against a running mudflaps (create / update / prune)",
+ taskQueue: "fly",
+ phases: [
+ phase("Build", [build(".", { script: "build:fly" })]),
+ phase("Apply", [flyApplyStep("dist/fly.json", { endpoint: "http://localhost:4280", prune: true })]),
+ ],
+});
diff --git a/examples/fly-reconcile/package.json b/examples/fly-reconcile/package.json
new file mode 100644
index 000000000..29de3a8a4
--- /dev/null
+++ b/examples/fly-reconcile/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "fly-reconcile-example",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build:fly": "chant build src --lexicon fly -o dist/fly.json",
+ "build": "npm run build:fly",
+ "lint": "chant lint src"
+ },
+ "dependencies": {
+ "@intentius/chant": "*",
+ "@intentius/chant-lexicon-fly": "*",
+ "@intentius/chant-lexicon-temporal": "*"
+ },
+ "devDependencies": {
+ "typescript": "*"
+ }
+}
diff --git a/examples/fly-reconcile/src/infra.ts b/examples/fly-reconcile/src/infra.ts
new file mode 100644
index 000000000..d9c45d27e
--- /dev/null
+++ b/examples/fly-reconcile/src/infra.ts
@@ -0,0 +1,36 @@
+import { App, Machine, Volume, MachineConfig, MachineGuest, MachineMount, Fly } from "@intentius/chant-lexicon-fly";
+
+// A small multi-resource Fly stack: one app, a persistent volume, and two
+// machines — `web` (which mounts the volume) and `worker`. It exists to show the
+// reconcile flyApply does — create → no-op re-apply → in-place update →
+// owned-only prune. The serializer stamps `managed-by: chant` into each
+// machine's `config.metadata`, the ownership marker the prune reads back.
+//
+// `org_slug` is required by the Machines API. `Fly.OrgSlug` resolves from
+// `FLY_ORG` at build time, defaulting to `personal` offline.
+const app = new App({ name: "fly-reconcile-demo", org_slug: Fly.OrgSlug });
+
+// A persistent volume. flyApply creates it before any machine that mounts it —
+// the dependency order is on the wire (the volume is POSTed first).
+const data = new Volume({ name: "data", region: "iad", size_gb: 1 });
+
+const guest = new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 });
+
+const web = new Machine({
+ name: "web",
+ region: "iad",
+ config: new MachineConfig({
+ image: "flyio/hellofly:latest",
+ guest,
+ // The mount references the declared volume by name (FLY011 checks this).
+ mounts: [new MachineMount({ volume: "data", path: "/data" })],
+ }),
+});
+
+const worker = new Machine({
+ name: "worker",
+ region: "iad",
+ config: new MachineConfig({ image: "flyio/hellofly:latest", guest }),
+});
+
+export { app, data, web, worker };
diff --git a/examples/fly-reconcile/tsconfig.json b/examples/fly-reconcile/tsconfig.json
new file mode 100644
index 000000000..d0db1f3c7
--- /dev/null
+++ b/examples/fly-reconcile/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "outDir": "./dist",
+ "rootDir": "."
+ },
+ "include": ["src", "ops"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/lexicons/fly/src/op/activities/emulator-images.ts b/lexicons/fly/src/op/activities/emulator-images.ts
index 51bd345e0..de19b33ba 100644
--- a/lexicons/fly/src/op/activities/emulator-images.ts
+++ b/lexicons/fly/src/op/activities/emulator-images.ts
@@ -15,7 +15,7 @@
*/
/** Pinned mudflaps (Fly Machines / flaps emulator) image. */
-export const MUDFLAPS_IMAGE = "ghcr.io/intentius/mudflaps:0.4.0";
+export const MUDFLAPS_IMAGE = "ghcr.io/intentius/mudflaps:0.4.1";
/** Pinned spritzer (Fly Sprites emulator) image. */
export const SPRITZER_IMAGE = "ghcr.io/intentius/spritzer:0.4.0";