diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog for `fragr`
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to the
+[Haskell Package Versioning Policy](https://pvp.haskell.org/).
+
+## 0.1.0.0 - 2026-07-22
+
+- Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2026 IC Rainbow
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1.  Redistributions of source code must retain the above copyright notice, this
+    list of conditions and the following disclaimer.
+
+2.  Redistributions in binary form must reproduce the above copyright notice,
+    this list of conditions and the following disclaimer in the documentation
+    and/or other materials provided with the distribution.
+
+3.  Neither the name of the copyright holder nor the names of its contributors
+    may be used to endorse or promote products derived from this software
+    without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,250 @@
+# fragr
+
+A frame graph (a.k.a. render graph) engine in Haskell, after the GDC 2017
+talk *"FrameGraph: Extensible Rendering Architecture in Frostbite"*
+(Yuriy O'Donnell). See [SPEC.md](./SPEC.md) for the full language-neutral
+specification this package implements.
+
+The library is renderer-agnostic: it knows nothing about GPUs, textures,
+or graphics APIs. It is a generic engine for declaring a DAG of *passes*
+over *virtual resources* — it culls unused work, computes resource
+lifetimes, and executes the surviving passes in order, creating and
+destroying resources just-in-time.
+
+## Quick start
+
+1. Give your resource type a `FG.Resource` instance.
+2. Each frame: build the graph, `FG.compile`, `FG.execute`, discard.
+3. See it run: `stack exec fragr-exe execute`.
+
+```haskell
+import Fragr qualified as FG
+
+-- 1. The resource contract:
+instance FG.Resource Buffer where
+  type Desc Buffer = BufferDesc    -- allocation descriptor
+  type Alloc Buffer = StagingPool  -- opaque, forwarded from execute
+  type Ctx Buffer = CommandBuffer  -- opaque, forwarded from execute
+  createResource desc pool = ...      -- pop a slot of desc's size, or allocate
+  destroyResource desc pool buf = ... -- push the slot back
+  -- optional: a Flags type, the hooks it feeds (preRead / preWrite, and
+  -- preAcquire / preRelease under executeQueued), describeDesc
+
+-- Pass data: a record of the handles the setup declared.
+data Upload = Upload
+  { staging :: Handle Buffer
+  , synced :: Handle Buffer
+  }
+
+main = do
+  pool <- newStagingPool  -- persists across frames
+
+  -- 2. Each frame: rebuild, compile, execute.
+  g <- FG.newFrameGraph
+  mesh <- FG.importResource g "mesh" meshDesc gpuMesh
+
+  up <- FG.addPass g "Upload"
+    do
+      staging <- FG.create @Buffer "staging" stagingDesc
+      staging' <- FG.write staging
+      synced <- FG.write mesh  -- writing an import forces a side effect
+      pure Upload{staging = staging', synced}
+    \up -> do
+      buf <- FG.get @Buffer up.staging
+      cb <- FG.askCtx
+      ... -- memcpy the chunk into buf, record the copy into the mesh
+
+  FG.addPass_ g "Draw"
+    do
+      FG.read up.synced
+      FG.setSideEffect
+    do
+      cb <- FG.askCtx
+      ... -- record draws against the synced mesh
+
+  FG.compile g
+  FG.execute g cmdBuffer pool
+```
+
+## The frame model
+
+A frame is a single-use object: build, `compile`, `execute`, discard —
+the frame loop rebuilds it every frame, Frostbite-style. `compile` is
+cheap enough for the hot path.
+
+Per-frame values (camera, exposure, the swapchain image) travel through
+the `ctx` argument of `execute` or are captured when the passes are
+re-registered.
+
+The `alloc` argument is the piece that *outlives* frames: transients are
+virtual, and `createResource` / `destroyResource` are where the
+allocation strategy lives. Back them with a pool and a rebuilt frame's
+transients recycle their memory instead of touching the driver. A
+transient's slot frees the moment its last user ends — in the example
+above, the staging slot returns to the pool before the next pass runs.
+
+## Setup and execution
+
+Pass *setup* runs in the `Build` monad: declarations implicitly target
+the pass under construction. Setup is a declaration — anything a pass
+needs from the outside world is produced before `addPass` and captured.
+
+The *execution* callback runs in `Exec`, a `MonadIO` — recording
+commands is what it is for. It carries:
+
+- the pass's resource accessor: `FG.get`, `FG.getDesc` (only for handles
+  the pass declared);
+- the frame context: `FG.askCtx`.
+
+Handles are *versioned*: writing a resource you did not create in the
+same pass renames it and returns a fresh handle. Always keep the
+returned handle (`h' <- FG.write h`). For terminal writes whose minted
+handle is dead by design, `write_` / `writeWith_` discard it visibly,
+and `addPass_` registers a sink pass (present, readback) with no pass
+data — a sink then composes with zero `_ <-` binds.
+
+Passes whose outputs nobody consumes are culled. Three ways out:
+
+- `FG.setSideEffect` marks the pass itself observable;
+- writing an `importResource` marks it automatically — the contents are
+  observable from outside the graph. Import a target only this graph's
+  passes care about with `importScratch` instead, and its writers stay
+  cullable like a transient's;
+- `FG.finalize g h flags` declares a resource's terminal state (e.g.
+  presentable) as exactly such a pass, placed on the queue of the pass
+  that produced the handle.
+
+## Flags and hooks
+
+`readWith` / `writeWith` carry per-resource `Flags` to the `preRead` /
+`preWrite` hooks. Each `Resource` instance picks its own type (an image
+layout ADT, a stage/access mask pair, ...) via the `Flags` associated
+type, and the handle's resource type ties the declaration to it —
+passing another resource's flags is a type error. Plain `read` / `write`
+declare the access with no flags and fire no hooks.
+
+`FG.addPreExec` installs a per-pass flush point between the hooks and
+the pass callback, so hooks can accumulate work into `ctx` (say, image
+barriers) and emit it as one batched command. `FG.addPostExec` is its
+counterpart after the callback and the `preRelease` hooks, batching
+release barriers the same way. Flush points compose, so an adapter
+library and the application can hook the same graph.
+
+## Graph rendering
+
+Both written against the read-only `FG.snapshot` view — as any custom
+writer would be:
+
+- `Fragr.Snapshot.Dot` — Graphviz DOT export of the compiled graph
+  (`Dot.dump g`, or `Dot.dumpWith` for the `Dot.Options`). One vertex
+  per resource *version*, overlaid with the write-after-read edges that
+  order passes no data flows between. Opting into `stratify` pins one
+  row per dependency level, so the widest row is the concurrency the
+  graph permits. `Dot.dumpSync` renders the compiled schedule instead:
+  one lane per queue in submission order, overlaid with the timeline
+  waits, event pairs, ownership transfers and retire points.
+- `Fragr.Snapshot.JSON` — the interactive viewer's JSON document
+  (`Json.dump g`), which instead collapses each resource's whole rename
+  chain into one record. The serializer is hand-rolled, keeping the
+  library free of an aeson dependency.
+
+## Multi-queue support
+
+On top of the single-queue `execute`, the library can schedule the
+surviving passes across several submission queues (e.g. a Vulkan
+graphics queue and an async-compute queue) — still renderer-agnostically:
+it only knows about `QueueId`s, per-queue timeline values and
+`EventId`s, never about a real semaphore or barrier.
+
+To use it:
+
+1. Assign passes to queues in their setup blocks:
+   `FG.setQueue (QueueId 1)` (default: `defaultQueue`, queue 0).
+2. `FG.compile g` — or `compileWith` with a queue-family partition.
+3. Size per-queue state (command buffers) from `FG.executingQueues g`.
+4. `FG.executeQueued g backend (Just recycleQueue) ctx alloc`.
+
+### The schedule
+
+`compile` derives a `PassSync` schedule per surviving pass:
+
+- **cross-queue waits** — the timeline values a pass must wait for, one
+  per foreign producer queue, deduplicated by a per-queue watermark (a
+  value an earlier same-queue pass already waited for is dropped); each
+  kept `Wait` lists the accesses (handles + flags) it protects, so a
+  driver can derive its wait scope (e.g. `waitDstStageMask`) instead of
+  over-synchronizing;
+- **timeline signal** — the `i`-th executing pass on a queue signals
+  value `i` on completion;
+- **split-barrier events** — a same-queue dependency with a pass in
+  between gets a `signalEvents` / `waitEvents` pair, each `SyncEvent`
+  carrying its own pass's accesses for the barrier scopes; adjacent ones
+  rely on a plain barrier (the `preRead` / `preWrite` hook path);
+- **ownership transfer** — `releases` / `acquires` list the outputs
+  handed between queues as `Transfer`s carrying the *consuming* access's
+  flags (for a cross-queue rename, the renaming write's), so a backend
+  can record release/acquire barriers into the right target state; the
+  resource contract's `preRelease` hook fires on the producer (after its
+  callback) and `preAcquire` on the consumer (before its callback), with
+  the `addPreExec` / `addPostExec` flush points bracketing the callback
+  to batch what each side accumulates.
+
+No pass reordering happens: registration order stays execution order, so
+every cross-queue edge points backward and in-order submission plus
+waits cannot deadlock.
+
+### Queue families
+
+Ownership's real unit is the queue *family*: give `compileWith` a
+`QueueId -> FamilyId` partition and a fan-out to several queues of one
+family carries a single transfer — the family's first-registered
+consumer acquires for its siblings. Consumption on two distinct families
+is rejected (`ReleasedToTwoFamilies`) unless the resource has no single
+owner (e.g. Vulkan `CONCURRENT` sharing): imports read that off the
+object (`Resource.isShared`), created transients say `markShared`.
+Queues outside the partition (e.g. a host queue) keep per-queue
+transfers, which is also plain `compile`'s behavior for everything.
+
+An import last touched by one family and first used by another this
+frame has no producer edge to derive a transfer from. `importOwned g
+"mesh" meshDesc gpuMesh (QueueId 1)` names the owning queue and
+registers a synthetic pass on it, standing in for last frame's work —
+the release gains a queue to record on and the schedule a producer edge,
+with families, sibling waits and single-owner validation applying
+unchanged. Its writers stay cullable, like `importScratch`'s.
+
+### Driving the schedule
+
+`executeQueued g backend (Just recycleQueue) ctx alloc` walks the
+schedule through `backend :: QueueBackend`, a record of callbacks the
+library invokes around each pass (wait/acquire before, release/signal
+after) — it never names a GPU primitive itself. An import-only graph
+(fragr does scheduling and hooks, all resources owned outside) may pass
+`Nothing` instead.
+
+Deferred, Vulkan-style reclamation goes through a `RecycleQueue`:
+instead of destroying a transient inline, `executeQueued` retires it
+with the per-queue timeline values that must be reached first; `collect`
+(given the currently-reached timelines) frees everything whose
+requirements are met and whose in-use refcount is zero.
+
+The full schedule and retire requirements are also exposed through
+`FG.snapshot` (`PassInfo.sync` and `Snapshot.retires`) for inspection.
+See the `multi-queue` test group for a worked simulated-Vulkan backend.
+
+## The demo
+
+Run `stack exec fragr-exe execute`. It executes a small streaming frame
+twice against a staging-buffer pool and prints every allocation
+decision. What to watch for:
+
+1. Each upload pass borrows a staging transient, fills it, and copies
+   its chunk into the imported GPU mesh buffer.
+2. The staging slot retires the moment its upload ends, so chunk B
+   reuses chunk A's slot within the frame.
+3. The rebuilt second frame allocates nothing — one buffer serves every
+   upload.
+
+Under `executeQueued` the same retirement goes through the
+`RecycleQueue`, so a slot returns to the pool only once the GPU has
+actually finished the copy.
diff --git a/app/App/Demo.hs b/app/App/Demo.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Demo.hs
@@ -0,0 +1,127 @@
+{-# LANGUAGE TypeFamilies #-}
+
+{-|
+The demo frame graph and its staging-buffer pool.
+
+A tiny streaming frame: two upload passes each borrow a pooled staging
+buffer, fill it, and copy their chunk into an imported GPU mesh buffer; a
+draw pass consumes the synced mesh, and a dead pass gets culled. A staging
+transient retires the moment its upload ends, so chunk B reuses chunk A's
+slot within the frame and a second frame allocates nothing at all.
+-}
+module App.Demo
+  ( buildDemo
+  , StagingPool
+  , newStagingPool
+  ) where
+
+import Control.Monad.IO.Class (liftIO)
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text.IO
+
+import Fragr (FrameGraph, Handle)
+import Fragr qualified as FG
+
+-- | A mapped buffer stand-in: the name of the pool slot backing it.
+newtype Buffer = Buffer Text
+
+data BufferDesc = BufferDesc
+  { label :: Text
+  , size :: Int
+  }
+
+-- | Size-keyed free lists of retained slots, plus a slot-name counter.
+newtype StagingPool = StagingPool (IORef PoolState)
+
+data PoolState = PoolState
+  { nextSlot :: Int
+  , free :: Map Int [Buffer]
+  }
+
+newStagingPool :: IO StagingPool
+newStagingPool = StagingPool <$> newIORef PoolState{nextSlot = 0, free = Map.empty}
+
+instance FG.Resource Buffer where
+  type Desc Buffer = BufferDesc
+  type Alloc Buffer = StagingPool
+  type Ctx Buffer = ()
+
+  createResource desc (StagingPool ref) = do
+    pool <- readIORef ref
+    case Map.findWithDefault [] desc.size pool.free of
+      slot@(Buffer name) : rest -> do
+        writeIORef ref pool{free = Map.insert desc.size rest pool.free}
+        Text.IO.putStrLn ("  reuse " <> name <> " for " <> desc.label)
+        pure slot
+      [] -> do
+        let name = "buf" <> Text.pack (show pool.nextSlot)
+        writeIORef ref pool{nextSlot = pool.nextSlot + 1}
+        Text.IO.putStrLn ("  alloc " <> name <> " for " <> desc.label)
+        pure (Buffer name)
+
+  destroyResource desc (StagingPool ref) slot@(Buffer name) = do
+    Text.IO.putStrLn ("  recycle " <> name <> " after " <> desc.label)
+    modifyIORef' ref \pool -> pool{free = Map.insertWith (<>) desc.size [slot] pool.free}
+
+  describeDesc desc =
+    Text.pack (show desc.size) <> "B"
+
+-- | One upload's pass data: the borrowed staging slot and the synced mesh.
+data Upload = Upload
+  { staging :: Handle Buffer
+  , mesh :: Handle Buffer
+  }
+
+-- | Build and compile one frame's demo graph.
+buildDemo :: IO (FrameGraph () StagingPool)
+buildDemo = do
+  g <- FG.newFrameGraph
+
+  mesh <- FG.importResource g "mesh" BufferDesc{label = "mesh", size = 1024 * 1024} (Buffer "mesh")
+
+  meshA <- uploadChunk g "A" mesh
+  meshB <- uploadChunk g "B" meshA
+
+  FG.addPass_
+    g
+    "Draw"
+    do
+      FG.read meshB
+      FG.setSideEffect
+    (liftIO (putStrLn "  run Draw (mesh in sync)"))
+
+  -- This one is dead code and gets culled.
+  FG.addPass_
+    g
+    "Orphan"
+    do
+      h <- FG.create @Buffer "staging.orphan" BufferDesc{label = "staging.orphan", size = 64 * 1024}
+      FG.write_ h
+    (liftIO (putStrLn "  run Orphan (should never happen)"))
+
+  FG.compile g
+  pure g
+
+{- | Stream one chunk: borrow a staging slot, fill it, copy it into the
+mesh. The staging transient's last user is this very pass, so the slot
+returns to the pool as soon as the upload ends.
+-}
+uploadChunk :: FrameGraph () StagingPool -> Text -> Handle Buffer -> IO (Handle Buffer)
+uploadChunk g chunk mesh = do
+  up <-
+    FG.addPass
+      g
+      ("Upload " <> chunk)
+      do
+        staging <- FG.create @Buffer ("staging." <> chunk) BufferDesc{label = "staging." <> chunk, size = 64 * 1024}
+        staging' <- FG.write staging
+        mesh' <- FG.write mesh
+        pure Upload{staging = staging', mesh = mesh'}
+      \up -> do
+        Buffer slot <- FG.get @Buffer up.staging
+        liftIO (Text.IO.putStrLn ("  run Upload " <> chunk <> " (fill " <> slot <> ", copy to mesh)"))
+  pure up.mesh
diff --git a/app/App/Options.hs b/app/App/Options.hs
new file mode 100644
--- /dev/null
+++ b/app/App/Options.hs
@@ -0,0 +1,41 @@
+-- | Command-line interface for the demo executable.
+module App.Options
+  ( Command (..)
+  , parseCommand
+  ) where
+
+import Options.Applicative
+
+import Fragr.Snapshot.Dot qualified as Dot
+
+-- | The mode to run in, selected by subcommand.
+data Command
+  = Dot Dot.Options
+  | Json
+  | Execute
+  deriving stock (Eq, Show)
+
+-- | Parse the process arguments, handling @--help@ and errors.
+parseCommand :: IO Command
+parseCommand = execParser (info (commandP <**> helper) desc)
+  where
+    desc = fullDesc <> progDesc "Build the demo frame graph and dump or execute it."
+
+commandP :: Parser Command
+commandP =
+  hsubparser
+    ( command "dot" (info (Dot <$> dotOptionsP) (progDesc "Dump the graph as Graphviz DOT"))
+        <> command "json" (info (pure Json) (progDesc "Dump the graph as viewer JSON (SPEC §8.3)"))
+        <> command "execute" (info (pure Execute) (progDesc "Execute the graph with logging callbacks"))
+    )
+
+dotOptionsP :: Parser Dot.Options
+dotOptionsP = build <$> clusterImportsP <*> stratifyP <*> antiEdgesP
+  where
+    build clusterImports stratify antiEdges = Dot.Options{clusterImports, stratify, antiEdges}
+    clusterImportsP =
+      switch (long "cluster-imports" <> help "Group the imported resources")
+    stratifyP =
+      switch (long "stratify" <> help "Pin one row per dependency level, instead of letting the layout rank the passes")
+    antiEdgesP =
+      flag True False (long "no-anti-edges" <> help "Hide the write-after-read edges")
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,31 @@
+{-|
+Demo entry point.
+
+Builds the example frame graph, then per the chosen subcommand dumps it as
+Graphviz DOT, dumps it as viewer JSON, or executes two frames against one
+staging pool, rebuilding the graph each frame.
+-}
+module Main (main) where
+
+import Data.Foldable (for_)
+import Data.Text.IO qualified as Text.IO
+
+import Fragr qualified as FG
+import Fragr.Snapshot.Dot qualified as Dot
+import Fragr.Snapshot.JSON qualified as Json
+
+import App.Demo (buildDemo, newStagingPool)
+import App.Options (Command (..), parseCommand)
+
+main :: IO ()
+main = do
+  cmd <- parseCommand
+  case cmd of
+    Dot opts -> buildDemo >>= Dot.dumpWith opts >>= Text.IO.putStrLn
+    Json -> buildDemo >>= Json.dump >>= Text.IO.putStrLn
+    Execute -> do
+      pool <- newStagingPool
+      for_ [1 :: Int, 2] \frame -> do
+        putStrLn ("frame " <> show frame <> ":")
+        g <- buildDemo
+        FG.execute g () pool
diff --git a/bench/Bench.hs b/bench/Bench.hs
new file mode 100644
--- /dev/null
+++ b/bench/Bench.hs
@@ -0,0 +1,118 @@
+{-# LANGUAGE TypeFamilies #-}
+
+{-| tasty-bench benchmarks: whole-frame build/compile/execute cycles, since
+a frame graph is a single-use object.
+-}
+module Main (main) where
+
+import Control.Monad (foldM)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Test.Tasty.Bench (bench, bgroup, defaultMain, whnfIO)
+
+import Fragr (Handle)
+import Fragr qualified as FG
+
+data Buffer = Buffer
+
+instance FG.Resource Buffer where
+  type Desc Buffer = Int
+  type Alloc Buffer = ()
+  type Ctx Buffer = ()
+  createResource _ () = pure Buffer
+  destroyResource _ () _ = pure ()
+
+main :: IO ()
+main =
+  defaultMain
+    [ bgroup
+        "chain (build+compile+execute)"
+        [ bench "10" $ whnfIO (chain 10)
+        , bench "100" $ whnfIO (chain 100)
+        , bench "1000" $ whnfIO (chain 1000)
+        ]
+    , bgroup
+        "culled (build+compile)"
+        [ bench "100" $ whnfIO (culled 100)
+        , bench "1000" $ whnfIO (culled 1000)
+        ]
+    , bgroup
+        "fan-out (build+compile+execute)"
+        [ bench "100" $ whnfIO (fanOut 100)
+        , bench "1000" $ whnfIO (fanOut 1000)
+        ]
+    ]
+
+{- | A linear chain: every pass reads its predecessor's output and writes a
+fresh resource of its own; the last pass has a side effect, so the whole
+chain survives culling and executes.
+-}
+chain :: Int -> IO ()
+chain n = do
+  g <- FG.newFrameGraph
+  h0 <- producer g 0
+  hLast <-
+    foldM
+      ( \prev i ->
+          FG.addPass
+            g
+            (passName i)
+            do
+              FG.read prev
+              h <- FG.create @Buffer (bufName i) i
+              FG.write h
+            \_data -> pure ()
+      )
+      h0
+      [1 .. n - 1]
+  FG.addPass_
+    g
+    "present"
+    do
+      FG.read hLast
+      FG.setSideEffect
+    (pure ())
+  FG.compile g
+  FG.execute g () ()
+
+-- | N independent passes whose outputs nobody reads: all culled.
+culled :: Int -> IO ()
+culled n = do
+  g <- FG.newFrameGraph
+  mapM_ (producer g) [0 .. n - 1]
+  FG.compile g
+
+-- | One producer, N side-effecting readers.
+fanOut :: Int -> IO ()
+fanOut n = do
+  g <- FG.newFrameGraph
+  h <- producer g 0
+  mapM_
+    ( \i ->
+        FG.addPass_
+          g
+          (passName i)
+          do
+            FG.read h
+            FG.setSideEffect
+          (pure ())
+    )
+    [1 .. n]
+  FG.compile g
+  FG.execute g () ()
+
+producer :: FG.FrameGraph () () -> Int -> IO (Handle Buffer)
+producer g i =
+  FG.addPass
+    g
+    (passName i)
+    do
+      h <- FG.create @Buffer (bufName i) i
+      FG.write h
+    \_data -> pure ()
+
+passName :: Int -> Text
+passName i = "pass" <> Text.pack (show i)
+
+bufName :: Int -> Text
+bufName i = "buf" <> Text.pack (show i)
diff --git a/fragr.cabal b/fragr.cabal
new file mode 100644
--- /dev/null
+++ b/fragr.cabal
@@ -0,0 +1,159 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.39.6.
+--
+-- see: https://github.com/sol/hpack
+
+name:           fragr
+version:        0.1.0.0
+synopsis:       Frame graph (render graph) engine, renderer-agnostic
+category:       Graphics
+author:         IC Rainbow
+maintainer:     aenor.realm@gmail.com
+copyright:      2026 IC Rainbow
+license:        BSD-3-Clause
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://gitlab.com/dpwiz/fragr
+
+flag examples
+  description: Build examples.
+  manual: True
+  default: False
+
+library
+  exposed-modules:
+      Fragr
+      Fragr.Builder
+      Fragr.Compile
+      Fragr.Error
+      Fragr.Exec
+      Fragr.Execute
+      Fragr.Graph
+      Fragr.Recycle
+      Fragr.Resource
+      Fragr.Snapshot
+      Fragr.Snapshot.Dot
+      Fragr.Snapshot.JSON
+      Fragr.Sync
+      Fragr.Types
+  other-modules:
+      Paths_fragr
+  autogen-modules:
+      Paths_fragr
+  hs-source-dirs:
+      src
+  default-extensions:
+      BlockArguments
+      DerivingStrategies
+      DuplicateRecordFields
+      LambdaCase
+      NoFieldSelectors
+      OverloadedRecordDot
+      OverloadedStrings
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , text
+    , transformers
+  default-language: GHC2021
+
+executable fragr-exe
+  main-is: Main.hs
+  other-modules:
+      App.Demo
+      App.Options
+      Paths_fragr
+  autogen-modules:
+      Paths_fragr
+  hs-source-dirs:
+      app
+  default-extensions:
+      BlockArguments
+      DerivingStrategies
+      DuplicateRecordFields
+      LambdaCase
+      NoFieldSelectors
+      OverloadedRecordDot
+      OverloadedStrings
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , fragr
+    , optparse-applicative
+    , text
+    , transformers
+  default-language: GHC2021
+  if !flag(examples)
+    buildable: False
+
+test-suite fragr-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Spec.Alias
+      Spec.Behavior
+      Spec.Dot
+      Spec.Error
+      Spec.JSON
+      Spec.MultiQueue
+      Spec.Setup
+      Utils
+      Paths_fragr
+  autogen-modules:
+      Paths_fragr
+  hs-source-dirs:
+      test
+  default-extensions:
+      BlockArguments
+      DerivingStrategies
+      DuplicateRecordFields
+      LambdaCase
+      NoFieldSelectors
+      OverloadedRecordDot
+      OverloadedStrings
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , fragr
+    , tasty
+    , tasty-hunit
+    , text
+    , transformers
+  default-language: GHC2021
+
+benchmark fragr-bench
+  type: exitcode-stdio-1.0
+  main-is: Bench.hs
+  other-modules:
+      Paths_fragr
+  autogen-modules:
+      Paths_fragr
+  hs-source-dirs:
+      bench
+  default-extensions:
+      BlockArguments
+      DerivingStrategies
+      DuplicateRecordFields
+      LambdaCase
+      NoFieldSelectors
+      OverloadedRecordDot
+      OverloadedStrings
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -O2
+  build-depends:
+      base >=4.7 && <5
+    , containers
+    , fragr
+    , tasty-bench
+    , text
+    , transformers
+  default-language: GHC2021
diff --git a/src/Fragr.hs b/src/Fragr.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr.hs
@@ -0,0 +1,203 @@
+{-|
+Frame graph (a.k.a. render graph) engine, after the GDC 2017 talk
+/"FrameGraph: Extensible Rendering Architecture in Frostbite"/.
+
+Intended for qualified import:
+
+@
+import Fragr qualified as FG
+@
+
+The frame loop, every frame:
+
+1. 'newFrameGraph'; 'importResource' the external objects.
+2. 'addPass' each pass: declare accesses in the setup block, record work
+   in the execution callback.
+3. 'compile' — culling, lifetimes, the sync schedule.
+4. 'execute' (or 'executeQueued'), then discard the graph.
+
+The graph is a single-use, single-threaded object rebuilt every frame,
+Frostbite-style. Per-frame values (camera, exposure, the swapchain image)
+travel through @ctx@ or are captured when the frame's passes are
+re-registered; 'compile' is cheap by design, and imports are re-registered
+each frame anyway since the external object may change identity.
+-}
+module Fragr
+  ( -- * Graph lifecycle
+    FrameGraph
+  , newFrameGraph
+  , compile
+  , compileWith
+  , execute
+  , validateAliasGroups
+
+    -- * Multi-queue execution
+    -- $multiqueue
+  , executeQueued
+  , QueueBackend (..)
+  , executingQueues
+
+    -- * Setup: registering passes
+  , addPass
+  , addPass_
+  , Build
+  , create
+  , read
+  , readWith
+  , write
+  , write_
+  , writeWith
+  , writeWith_
+  , setSideEffect
+  , setQueue
+
+    -- * Setup: graph-level operations
+  , importResource
+  , importScratch
+  , importOwned
+  , markShared
+  , finalize
+  , addPreExec
+  , addPostExec
+  , isValid
+  , getDescriptor
+
+    -- * Execution-time resource access
+  , Exec
+  , askCtx
+  , get
+  , getDesc
+
+    -- * The resource contract
+  , Resource (..)
+
+    -- * Handles and access flags
+  , Handle (..)
+  , handleId
+  , SomeHandle (..)
+  , someHandleId
+  , Access (..)
+  , accessId
+
+    -- * Queues, timelines and events
+  , QueueId (..)
+  , FamilyId (..)
+  , EventId (..)
+  , defaultQueue
+
+    -- * Deferred reclamation (recycle queue)
+  , RecycleQueue
+  , RetireItem
+  , newRecycleQueue
+  , mkRetireItem
+  , retireItem
+  , acquireItem
+  , releaseItem
+  , collect
+
+    -- * Errors
+  , FragrError (..)
+
+    -- * Introspection (for debug output, see "Fragr.Snapshot.Dot" and "Fragr.Snapshot.JSON")
+  , Snapshot (..)
+  , PassInfo (..)
+  , NodeInfo (..)
+  , EntryInfo (..)
+  , PassSync (..)
+  , Wait (..)
+  , SyncEvent (..)
+  , Transfer (..)
+  , transferId
+  , RetireInfo (..)
+  , snapshot
+  ) where
+
+import Prelude hiding (read)
+
+import Fragr.Builder (Build, addPass, addPass_, create, finalize, importOwned, read, readWith, setQueue, setSideEffect, write, writeWith, writeWith_, write_)
+import Fragr.Compile (compile, compileWith, validateAliasGroups)
+import Fragr.Error (FragrError (..))
+import Fragr.Exec (Exec, askCtx, get, getDesc)
+import Fragr.Execute (QueueBackend (..), execute, executeQueued, executingQueues)
+import Fragr.Graph (FrameGraph, addPostExec, addPreExec, getDescriptor, importResource, importScratch, isValid, markShared, newFrameGraph)
+import Fragr.Recycle (RecycleQueue, RetireItem, acquireItem, collect, mkRetireItem, newRecycleQueue, releaseItem, retireItem)
+import Fragr.Resource (Access (..), Resource (..), accessId)
+import Fragr.Snapshot (EntryInfo (..), NodeInfo (..), PassInfo (..), RetireInfo (..), Snapshot (..), snapshot)
+import Fragr.Sync (PassSync (..), SyncEvent (..), Transfer (..), Wait (..), transferId)
+import Fragr.Types (EventId (..), FamilyId (..), Handle (..), QueueId (..), SomeHandle (..), defaultQueue, handleId, someHandleId)
+
+{- $multiqueue
+The core 'execute' assumes a single, in-order queue: it destroys each
+transient inline, right after its last user. On top of that, 'compile' also
+derives a per-pass synchronization schedule ('PassSync') for running the
+surviving passes across several queues, and 'executeQueued' drives that
+schedule through a 'QueueBackend' seam without the library ever naming a
+semaphore, event or barrier.
+
+Ordering:
+
+* No reordering. Registration order stays execution order; each queue is
+  submitted in its own registration order. Because a resource is always
+  produced before it is read, and read only before it is renamed (the
+  rename rule), every cross-queue dependency edge — read-after-write and
+  write-after-read alike — points /backward/ in registration order, so
+  in-order per-queue submission plus timeline waits cannot deadlock.
+
+* One timeline per queue. The @i@-th executing pass on a queue (1-based)
+  signals value @i@ on completion; that is its 'signal'.
+
+* Cross-queue waits. For each consumer, every foreign-queue producer
+  contributes a wait for that producer's 'signal' (read-after-write), and
+  for each renaming writer, every foreign-queue reader of the renamed
+  version contributes one likewise (write-after-read); these are collapsed
+  to the maximum per foreign queue and then deduplicated against a
+  per-(consumer-queue, producer-queue) watermark, since a value already
+  awaited by an earlier pass on the same consumer queue is implied. Each
+  kept 'Wait' carries the accesses it protects, so a backend can derive
+  its wait scope instead of over-synchronizing.
+
+* Same-queue dependencies. An edge whose producer and consumer are adjacent
+  in their queue's order needs only a plain barrier (the existing
+  'preRead' / 'preWrite' hook path). An edge (data or anti) with at least
+  one pass in between gets a split-barrier event pair ('signalEvents' /
+  'waitEvents'), each 'SyncEvent' carrying its own pass's accesses for the
+  barrier scopes.
+
+Ownership:
+
+* Ownership transfer. A cross-queue /data/ edge records a 'releases'
+  'Transfer' on the producer and an 'acquires' one on the consumer — both
+  carrying the consuming access's 'Flags' (for a rename, whose implicit
+  read declares none, the renaming write's) — so the backend can emit
+  release / acquire barriers; the resource contract additionally fires
+  'preRelease' on the producer (after its callback) and 'preAcquire' on
+  the consumer (before its callback) for transfers with flags. The
+  'addPreExec' / 'addPostExec' flush points bracket the callback, batching
+  what the acquire and release hooks accumulate respectively.
+
+* Families. Ownership's real unit is the queue /family/ ('compileWith'):
+  under a @QueueId -> FamilyId@ partition, a fan-out to several queues of
+  one family carries a single transfer — the family's first-registered
+  consumer acquires for its siblings, who each gain a wait on the
+  acquiring pass (the acquire barrier lives on its queue; the producer's
+  signal alone would let a sibling read before it) — and consumption on
+  two distinct families is rejected ('ReleasedToTwoFamilies') unless the
+  resource has no single owner (e.g. Vulkan @CONCURRENT@ sharing):
+  imports read that off the object ('isShared'), created transients say
+  'markShared'. Queues outside the partition (e.g. the host) keep
+  per-queue transfers, which is also plain 'compile''s behavior for
+  everything.
+
+* Frame boundaries. An import last touched by one family and first used
+  by another this frame has no producer edge to derive a transfer from.
+  'importOwned' names the owning queue and registers a synthetic pass on
+  it, standing in for last frame's work: the release gains a queue to
+  record on, the schedule a producer edge, and everything above —
+  families, sibling waits, single-owner validation — applies unchanged.
+
+Deferred reclamation uses a 'RecycleQueue': instead of destroying a
+transient inline, 'executeQueued' retires it with the per-queue timeline
+values that must be reached first ('entryRetire') plus a destroy action,
+and 'collect' reclaims everything whose timelines have passed and whose
+in-use refcount is zero.
+-}
diff --git a/src/Fragr/Builder.hs b/src/Fragr/Builder.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Builder.hs
@@ -0,0 +1,328 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+
+-- | The pass-declaration monad: registering passes and declaring accesses.
+module Fragr.Builder
+  ( addPass
+  , addPass_
+  , Build
+  , Builder (..)
+  , create
+  , read
+  , readWith
+  , write
+  , write_
+  , writeWith
+  , writeWith_
+  , setSideEffect
+  , setQueue
+  , finalize
+  , importOwned
+  ) where
+
+import Prelude hiding (read)
+
+import Control.Exception (throwIO)
+import Control.Monad (unless, void, when)
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Reader (ReaderT (..))
+import Data.Foldable (find)
+import Data.IORef
+import Data.IntSet qualified as IntSet
+import Data.Sequence ((|>))
+import Data.Sequence qualified as Seq
+import Data.Text (Text)
+
+import Fragr.Error (FragrError (..))
+import Fragr.Exec (Exec)
+import Fragr.Graph (FrameGraph (..), PassNode (..), ResourceEntry (..), ResourceNode (..), appendEntry, appendNode, assertValid, entryAt, entryOf, importScratch, markObserved, nodeAt, producedNodes)
+import Fragr.Resource (Access (..), Resource (..), accessId)
+import Fragr.Types (Handle, QueueId, SomeHandle (..), defaultQueue, handleId, someHandleId)
+
+{- |
+Register a pass.
+
+The setup block runs immediately, exactly once, before 'addPass' returns;
+it declares the pass's accesses in the 'Build' monad and returns the pass
+data (typically a record of handles). The execution callback is stored and
+runs later, during 'Fragr.Execute.execute', only if the pass survives
+culling; it receives the pass data and runs in 'Exec', which carries this
+pass's resource accessor ('Fragr.Exec.get', 'Fragr.Exec.getDesc') and the
+context given to 'Fragr.Execute.execute' ('Fragr.Exec.askCtx').
+
+Registration order is execution order; no reordering is performed.
+Returns the pass data produced by the setup block.
+-}
+{-# INLINEABLE addPass #-}
+addPass
+  :: (MonadIO m)
+  => FrameGraph ctx alloc
+  -> Text
+  -> Build ctx alloc d
+  -> (d -> Exec ctx alloc ())
+  -> m d
+addPass g passName setup exec = liftIO do
+  draftRef <-
+    newIORef
+      PassDraft
+        { draftCreates = []
+        , draftReads = []
+        , draftWrites = []
+        , draftSideEffect = False
+        , draftQueue = defaultQueue
+        }
+  dat <-
+    runReaderT
+      setup
+      Builder
+        { graph = g
+        , draftRef
+        }
+  draft <- readIORef draftRef
+  passes <- readIORef g.passesRef
+  let node =
+        PassNode
+          { passId = Seq.length passes
+          , name = passName
+          , creates = reverse draft.draftCreates
+          , reads = reverse draft.draftReads
+          , writes = reverse draft.draftWrites
+          , declared =
+              IntSet.fromList $
+                map someHandleId draft.draftCreates
+                  <> map accessId (draft.draftReads <> draft.draftWrites)
+          , sideEffect = draft.draftSideEffect
+          , queue = draft.draftQueue
+          , run = runReaderT (exec dat)
+          }
+  writeIORef g.passesRef (passes |> node)
+  -- Registering a pass invalidates any prior compilation:
+  -- 'Fragr.Execute.execute' would otherwise run against a stale schedule
+  -- (silently skipping the new pass, or failing to find its 'passSync').
+  writeIORef g.compiledRef Nothing
+  pure dat
+
+{- |
+'addPass' for sink passes (present, readback, metering): the setup returns
+no pass data, so the execution callback drops the then-vestigial data
+argument. Together with 'write_' / 'writeWith_' a sink registers with zero
+discarded binds.
+-}
+{-# INLINE addPass_ #-}
+addPass_
+  :: (MonadIO m)
+  => FrameGraph ctx alloc
+  -> Text
+  -> Build ctx alloc ()
+  -> Exec ctx alloc ()
+  -> m ()
+addPass_ g passName setup exec = addPass g passName setup (const exec)
+
+{- |
+The pass-declaration monad: the setup argument of 'addPass'. Declarations
+('create', 'read', 'write', 'setQueue', 'setSideEffect') implicitly target
+the pass being set up.
+
+Setup is a declaration, not an effect: values a pass needs from the
+outside world (device caps, per-frame state, scratch refs) are produced
+before 'addPass' and captured, in line with the build-once-per-frame
+model. The full reader surface is inherited for whoever insists.
+-}
+type Build ctx alloc = ReaderT (Builder ctx alloc) IO
+
+-- | The environment 'Build' reads: the graph plus the open pass draft.
+data Builder ctx alloc = Builder
+  { graph :: FrameGraph ctx alloc
+  , draftRef :: IORef PassDraft
+  }
+
+data PassDraft = PassDraft
+  { draftCreates :: [SomeHandle]
+  , draftReads :: [Access]
+  , draftWrites :: [Access]
+  , draftSideEffect :: Bool
+  , draftQueue :: QueueId
+  }
+
+{- |
+Declare a new transient resource, created and destroyed by the graph. The
+resource object is /not/ materialized here; 'createResource' runs during
+'Fragr.Execute.execute', just before the first pass that needs it.
+-}
+{-# INLINEABLE create #-}
+create
+  :: forall r ctx alloc
+   . (Resource r, Alloc r ~ alloc, Ctx r ~ ctx)
+  => Text
+  -> Desc r
+  -> Build ctx alloc (Handle r)
+create resName desc = ReaderT \b -> do
+  objRef <- newIORef (Nothing @r)
+  h <- appendEntry b.graph resName False False desc objRef
+  modifyIORef' b.draftRef \d -> d{draftCreates = SomeHandle h : d.draftCreates}
+  pure h
+
+-- | Like 'readWith', without flags: no hook fires for the access.
+{-# INLINEABLE read #-}
+read :: (Resource r) => Handle r -> Build ctx alloc ()
+read h = ReaderT \b -> declareRead b h Nothing
+
+{- |
+Declare that the pass reads the resource version named by the handle; the
+flags reach the resource's 'preRead' hook. Reads never rename, so no new
+handle is minted. Exact duplicates (same handle /and/ same flags) are
+recorded once.
+
+Fatal errors: stale handle; handle created or written by this same pass
+(within one pass a resource is either input or output).
+-}
+{-# INLINEABLE readWith #-}
+readWith :: (Resource r) => Handle r -> Flags r -> Build ctx alloc ()
+readWith h flags = ReaderT \b -> declareRead b h (Just flags)
+
+declareRead :: (Resource r) => Builder ctx alloc -> Handle r -> Maybe (Flags r) -> IO ()
+declareRead b h flags = do
+  assertValid "read" b.graph h
+  d <- readIORef b.draftRef
+  when (SomeHandle h `elem` d.draftCreates || handleId h `elem` map accessId d.draftWrites) $
+    throwIO $
+      ReadsOwnOutput (SomeHandle h)
+  unless (Access{handle = h, flags} `elem` d.draftReads) $
+    writeIORef b.draftRef d{draftReads = Access{handle = h, flags} : d.draftReads}
+
+-- | Like 'writeWith', without flags: no hook fires for the access.
+{-# INLINEABLE write #-}
+write :: (Resource r) => Handle r -> Build ctx alloc (Handle r)
+write h = ReaderT \b -> declareWrite b h Nothing
+
+{- | 'write' for a terminal write: the minted handle is dead by design and
+dropped. Intentional discards stay visible and greppable; accidental ones
+stay type errors.
+-}
+{-# INLINE write_ #-}
+write_ :: (Resource r) => Handle r -> Build ctx alloc ()
+write_ h = void (write h)
+
+{- |
+Declare that the pass writes the resource version named by the handle; the
+flags reach the resource's 'preWrite' hook.
+
+If this pass created the handle, the same handle is returned. Otherwise
+the resource is /renamed/: the pass implicitly also reads the old version
+(without flags), a new version (and node) is minted, and the /new/ handle
+is returned — the old one becomes stale. Always keep the returned handle:
+@h' <- FG.write h@.
+
+Writing an observed import ('importResource') automatically marks the pass
+as having a side effect (it must never be culled); writes to an
+'importScratch' resource stay cullable.
+-}
+{-# INLINEABLE writeWith #-}
+writeWith :: (Resource r) => Handle r -> Flags r -> Build ctx alloc (Handle r)
+writeWith h flags = ReaderT \b -> declareWrite b h (Just flags)
+
+-- | 'writeWith' for a terminal write, discarding the handle like 'write_'.
+{-# INLINE writeWith_ #-}
+writeWith_ :: (Resource r) => Handle r -> Flags r -> Build ctx alloc ()
+writeWith_ h flags = void (writeWith h flags)
+
+declareWrite :: (Resource r) => Builder ctx alloc -> Handle r -> Maybe (Flags r) -> IO (Handle r)
+declareWrite b h flags = do
+  assertValid "write" b.graph h
+  node <- nodeAt b.graph h
+  entry <- entryAt b.graph node.resourceId
+  when entry.observed $ markSideEffect b
+  d <- readIORef b.draftRef
+  if SomeHandle h `elem` d.draftCreates then do
+    unless (Access{handle = h, flags} `elem` d.draftWrites) $
+      writeIORef b.draftRef d{draftWrites = Access{handle = h, flags} : d.draftWrites}
+    pure h
+  else do
+    unless (Access{handle = h, flags = Nothing} `elem` d.draftReads) $
+      modifyIORef' b.draftRef \d' -> d'{draftReads = Access{handle = h, flags = Nothing} : d'.draftReads}
+    v <- readIORef entry.versionRef
+    let version' = v + 1
+    writeIORef entry.versionRef version'
+    h' <- appendNode b.graph entry.entryId version'
+    modifyIORef' b.draftRef \d' -> d'{draftWrites = Access{handle = h', flags} : d'.draftWrites}
+    pure h'
+
+{- | Mark the pass as having an observable output besides graph resources
+(presenting to screen, CPU readback, ...): it is immune to culling.
+-}
+{-# INLINE setSideEffect #-}
+setSideEffect :: Build ctx alloc ()
+setSideEffect = ReaderT markSideEffect
+
+markSideEffect :: Builder ctx alloc -> IO ()
+markSideEffect b = modifyIORef' b.draftRef \d -> d{draftSideEffect = True}
+
+{- | Assign the pass to a submission queue (default 'defaultQueue'). The
+schedule computed by 'Fragr.Compile.compile' derives cross-queue timeline waits,
+same-queue split-barrier events and ownership transfers from these
+assignments; see 'Fragr.Sync.PassSync' and 'Fragr.Execute.executeQueued'.
+-}
+{-# INLINE setQueue #-}
+setQueue :: QueueId -> Build ctx alloc ()
+setQueue q = ReaderT \b -> modifyIORef' b.draftRef \d -> d{draftQueue = q}
+
+{- |
+'Fragr.Graph.importResource' plus the queue whose family currently owns the
+contents: registers a synthetic pass on it, standing in for the work that
+left them there, so a first touch on another family this frame has a
+producer edge to derive the release / acquire pair from
+('Fragr.Compile.compileWith') — the release records on the owning queue even
+when nothing else runs there. Returns the handle the synthetic rename
+minted.
+
+A first touch within the owner's family melts like any same-family
+hand-off, and on an entirely untouched import the synthetic pass culls
+with the rest of the chain.
+-}
+{-# INLINEABLE importOwned #-}
+importOwned
+  :: forall r ctx alloc m
+   . (Resource r, Alloc r ~ alloc, Ctx r ~ ctx, MonadIO m)
+  => FrameGraph ctx alloc
+  -> Text
+  -> Desc r
+  -> r
+  -> QueueId
+  -> m (Handle r)
+importOwned g resName desc obj owner = liftIO do
+  h0 <- importScratch g resName desc obj
+  -- Scratch first, observed after: the synthetic write must not become a
+  -- side effect, or an unused import would defeat culling.
+  h <- addPass g ("import " <> resName) (setQueue owner *> write h0) (\_data -> pure ())
+  markObserved g h
+  pure h
+
+{- |
+Declare the terminal state of a resource: registers a synthetic
+side-effecting pass, named after the resource, that writes the handle with
+the given flags. The producing chain thus survives culling, and the
+'preWrite' hook runs as the resource's last access, leaving it in the
+required state (e.g. presentable). The handle minted by the underlying
+rename is discarded — the resource is final.
+
+The synthetic pass runs on the queue of the pass that produced the handle
+— created or wrote it; 'defaultQueue' for an unwritten import — so
+finalizing manufactures no cross-queue edge; a resource must be finalized
+elsewhere through an explicit pass.
+-}
+{-# INLINEABLE finalize #-}
+finalize :: (MonadIO m, Resource r) => FrameGraph ctx alloc -> Handle r -> Flags r -> m ()
+finalize g h flags = liftIO do
+  entry <- entryOf g h
+  passes <- readIORef g.passesRef
+  let
+    produces p = handleId h `elem` producedNodes p
+    producerQueue = maybe defaultQueue (.queue) (find produces passes)
+  addPass_
+    g
+    ("finalize " <> entry.name)
+    do
+      setQueue producerQueue
+      writeWith_ h flags
+      setSideEffect
+    (pure ())
diff --git a/src/Fragr/Compile.hs b/src/Fragr/Compile.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Compile.hs
@@ -0,0 +1,528 @@
+{-|
+The compile phase.
+
+Reference counting, culling, lifetime computation and the derivation of
+the multi-queue synchronization schedule.
+-}
+module Fragr.Compile
+  ( compile
+  , compileWith
+  , canExecute
+  , validateAliasGroups
+  ) where
+
+import Control.Exception (throwIO)
+import Control.Monad (guard, when)
+import Control.Monad.IO.Class (MonadIO (..))
+import Data.Either (partitionEithers)
+import Data.Foldable (for_, toList)
+import Data.IORef (readIORef, writeIORef)
+import Data.IntMap.Strict (IntMap)
+import Data.IntMap.Strict qualified as IntMap
+import Data.IntSet qualified as IntSet
+import Data.List (foldl')
+import Data.Map.Strict qualified as Map
+import Data.Maybe (listToMaybe, maybeToList)
+import Data.Sequence (Seq)
+import Data.Sequence qualified as Seq
+import Data.Set (Set)
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Word (Word64)
+
+import Fragr.Error (FragrError (..))
+import Fragr.Graph (FrameGraph (..), PassNode (..), ResourceEntry (..), ResourceNode (..), producedNodes, requireCompiled, touchedNodes)
+import Fragr.Resource (Access (..), accessId)
+import Fragr.Sync (Compiled (..), PassSync (..), SyncEvent (..), Transfer (..), Wait (..))
+import Fragr.Types (EventId (..), FamilyId, Handle (..), QueueId (..))
+
+{- |
+Reference-count, cull dead passes and resources, and compute resource
+lifetimes. Must run after all passes are registered and before 'Fragr.Execute.execute'.
+
+Cross-queue transfers come out one per consuming queue; 'compileWith'
+derives them per consuming /family/ instead.
+-}
+{-# INLINE compile #-}
+compile :: (MonadIO m) => FrameGraph ctx alloc -> m ()
+compile = compileWith []
+
+{- |
+'compile' under a queue-family partition: ownership of a cross-queue
+output moves once per consuming family — the family's first-registered
+consumer acquires, its sibling queues ride the schedule's waits — and a
+version consumed on two distinct families is 'ReleasedToTwoFamilies' (a
+single owner cannot be released twice; 'Fragr.Graph.markShared' resources
+are exempt). Queues left out of the partition (e.g. a host queue) keep
+their per-queue transfers.
+-}
+{-# INLINEABLE compileWith #-}
+compileWith :: (MonadIO m) => [(QueueId, FamilyId)] -> FrameGraph ctx alloc -> m ()
+compileWith families g = liftIO do
+  passes <- readIORef g.passesRef
+  nodes <- readIORef g.nodesRef
+  entries <- readIORef g.entriesRef
+  let
+    familyMap = IntMap.fromList do
+      (QueueId q, f) <- families
+      pure (q, f)
+    (compiled, violations) = compilePure passes nodes entries familyMap
+  for_ (listToMaybe violations) \(res, consumers) ->
+    throwIO $ ReleasedToTwoFamilies res consumers
+  writeIORef g.compiledRef $ Just compiled
+
+-- | Whether a pass survives culling, given the pass refcounts.
+{-# INLINE canExecute #-}
+canExecute :: IntMap Int -> PassNode ctx alloc -> Bool
+canExecute refs p =
+  p.sideEffect || IntMap.findWithDefault 0 p.passId refs > 0
+
+{- |
+Validate a plan-time aliasing decision: each member entry of every group
+must be written before it is read, since over aliased memory a first read
+observes whatever the previous occupant left behind. A renaming write
+counts as a read — it consumes the prior version. Group membership is by
+entry id (see 'Fragr.Snapshot.EntryInfo'); throws 'AliasedReadBeforeWrite'
+naming the resource and the reading pass. Fatal before 'compile'.
+
+Note this is the generic half of the rule: a backend still owes the
+full-overwrite discipline (clear, discard, or copy-over) that flags alone
+can express.
+-}
+validateAliasGroups :: (MonadIO m) => FrameGraph ctx alloc -> [[Int]] -> m ()
+validateAliasGroups g groups = liftIO do
+  compiled <- requireCompiled g "validateAliasGroups"
+  passes <- readIORef g.passesRef
+  nodes <- readIORef g.nodesRef
+  entries <- readIORef g.entriesRef
+  let
+    members = IntSet.fromList (concat groups)
+    entryOfNode h = (nodes `Seq.index` h).resourceId
+    -- The first executing pass that reads or writes each member entry, and
+    -- whether it reads it. Creates alone are materialization, not access.
+    firstTouch = IntMap.fromListWith (\_later earlier -> earlier) do
+      p <- filter (canExecute compiled.passRefs) (toList passes)
+      (e, readsIt) <-
+        IntMap.toList . IntMap.fromListWith (||) $
+          [(entryOfNode (accessId a), True) | a <- p.reads]
+            <> [(entryOfNode (accessId a), False) | a <- p.writes]
+      guard (e `IntSet.member` members)
+      pure (e, (p, readsIt))
+  for_ (concat groups) \e -> do
+    when (e < 0 || e >= Seq.length entries) $
+      throwIO (ResourceIdOutOfRange e)
+    for_ (IntMap.lookup e firstTouch) \(p, readsIt) ->
+      when readsIt $
+        throwIO (AliasedReadBeforeWrite (entries `Seq.index` e).name p.name)
+
+{- | Compile-internal ordering edge, with the accesses behind each
+endpoint: the producing pass's on 'srcAccess', the consuming pass's on
+'dstAccess'.
+-}
+data SyncEdge = SyncEdge
+  { from :: Int
+  , to :: Int
+  , srcAccess :: Set Access
+  , dstAccess :: Set Access
+  }
+
+-- Alongside the schedule: the 'ReleasedToTwoFamilies' violations, each a
+-- resource name with (first consumer, family) per consuming family.
+compilePure
+  :: Seq (PassNode ctx alloc)
+  -> Seq ResourceNode
+  -> Seq (ResourceEntry ctx alloc)
+  -> IntMap FamilyId
+  -> (Compiled, [(Text, [(Text, FamilyId)])])
+compilePure passes nodes entries families =
+  ( Compiled
+      { passRefs = finalPassRefs
+      , nodeRefs = finalNodeRefs
+      , passSync = syncMap
+      , passLevel = levelMap
+      , passAnti = antiAfterMap
+      , entryRetire = entryRetireMap
+      , retireAfter = retireAfterMap
+      }
+  , ownershipViolations
+  )
+  where
+    passList = toList passes
+
+    -- 4.1 initial reference counting
+    -- A pass's refcount is the number of distinct resource /nodes/ it
+    -- produces — creates and writes — that are still referenced. Count
+    -- distinct handles: writing one created handle several times (with
+    -- different flags) still names a single node, which culling can
+    -- decrement only once.
+    passRefs0 = IntMap.fromList do
+      p <- passList
+      pure (p.passId, IntSet.size (IntSet.fromList (producedNodes p)))
+    nodeRefs0 =
+      IntMap.unionWith
+        (+)
+        ( IntMap.fromList do
+            n <- toList nodes
+            pure (n.nodeId, 0)
+        )
+        ( IntMap.fromListWith (+) do
+            p <- passList
+            h <- map accessId p.reads
+            pure (h, 1 :: Int)
+        )
+    nodeProducer = IntMap.fromList do
+      p <- passList
+      h <- producedNodes p
+      pure (h, p.passId)
+    passSideEffect = IntMap.fromList do
+      p <- passList
+      pure (p.passId, p.sideEffect)
+    passReads = IntMap.fromList do
+      p <- passList
+      pure (p.passId, map accessId p.reads)
+
+    -- 4.2 culling by flood fill from unreferenced resource nodes
+    (finalPassRefs, finalNodeRefs) =
+      cull passRefs0 nodeRefs0 do
+        (n, 0) <- IntMap.toList nodeRefs0
+        pure n
+
+    cull pr nr = \case
+      [] -> (pr, nr)
+      n : rest ->
+        case IntMap.lookup n nodeProducer of
+          Nothing -> cull pr nr rest
+          Just pid
+            | IntMap.findWithDefault False pid passSideEffect -> cull pr nr rest
+            | otherwise ->
+                let
+                  refs = pr IntMap.! pid - 1
+                  pr' = IntMap.insert pid refs pr
+                in
+                  if refs > 0 then
+                    cull pr' nr rest
+                  else
+                    let
+                      dec (m, acc) h =
+                        let v = m IntMap.! h - 1
+                        in (IntMap.insert h v m, if v == 0 then h : acc else acc)
+                      (nr', newlyDead) = foldl' dec (nr, []) (IntMap.findWithDefault [] pid passReads)
+                    in
+                      cull pr' nr' (newlyDead <> rest)
+
+    entryOfNode h = (nodes `Seq.index` h).resourceId
+    entryIds f = Set.fromList do
+      e <- toList entries
+      guard (f e)
+      pure e.entryId
+    importedEntries = entryIds (.imported)
+    sharedEntries = entryIds (.shared)
+
+    -- Sync schedule (see the module-level notes). Everything below runs
+    -- over the /executing/ passes only.
+    executing = filter (canExecute finalPassRefs) passList
+    queueOf = IntMap.fromList do
+      p <- executing
+      pure (p.passId, p.queue)
+    qOf i = queueOf IntMap.! i
+
+    -- Per-queue registration-order position, 1-based: a pass at position i
+    -- signals value i on its queue's timeline.
+    posOf :: IntMap Word64
+    posOf = snd (foldl' step (IntMap.empty, IntMap.empty) executing)
+      where
+        step (counters, acc) p =
+          let
+            QueueId q = p.queue
+            n = IntMap.findWithDefault 0 q counters + 1 :: Word64
+          in
+            (IntMap.insert q n counters, IntMap.insert p.passId n acc)
+
+    -- The declared write accesses behind each written node; a node has
+    -- exactly one producer, so the node id alone keys them. Accumulated as
+    -- sets — deduplicated and ascending by construction, here and in
+    -- everything downstream that unions them.
+    writesByNode :: IntMap (Set Access)
+    writesByNode = IntMap.fromListWith (<>) do
+      p <- executing
+      a@Access{handle = Handle i} <- p.writes
+      pure (i, Set.singleton a)
+
+    -- Producer -> consumer edges, one per declared consumer read, carrying
+    -- the accesses on both endpoints.
+    dataEdges :: [SyncEdge]
+    dataEdges = do
+      c <- executing
+      a@Access{handle = Handle i} <- c.reads
+      pid <- maybeToList (IntMap.lookup i nodeProducer)
+      guard (IntMap.member pid queueOf)
+      guard (pid /= c.passId)
+      pure
+        SyncEdge
+          { from = pid
+          , to = c.passId
+          , srcAccess = IntMap.findWithDefault Set.empty i writesByNode
+          , dstAccess = Set.singleton a
+          }
+
+    -- Reader -> renamer anti-edges (write-after-read): the pass that renames
+    -- a node must run after every executing reader of that node. Readers
+    -- always register before the renamer (a rename makes the handle stale),
+    -- so anti-edges point backward like data edges.
+    nodeByVer = Map.fromList do
+      n <- toList nodes
+      pure ((n.resourceId, n.version), n.nodeId)
+    -- Old node id -> (renaming pass, the new version's node id).
+    renamerOf :: IntMap (Int, Int)
+    renamerOf = IntMap.fromList do
+      n <- toList nodes
+      next <- maybeToList (Map.lookup (n.resourceId, n.version + 1) nodeByVer)
+      w <- maybeToList (IntMap.lookup next nodeProducer)
+      guard (IntMap.member w queueOf)
+      pure (n.nodeId, (w, next))
+    antiEdges :: [SyncEdge]
+    antiEdges = do
+      r <- executing
+      a@Access{handle = Handle i} <- r.reads
+      (w, next) <- maybeToList (IntMap.lookup i renamerOf)
+      guard (w /= r.passId)
+      pure
+        SyncEdge
+          { from = r.passId
+          , to = w
+          , srcAccess = Set.singleton a
+          , dstAccess = IntMap.findWithDefault Set.empty next writesByNode
+          }
+
+    -- Ordering edges: data edges and anti-edges alike need waits (cross
+    -- queue) or events (same queue, gapped); only data edges transfer
+    -- ownership.
+    syncEdges :: [SyncEdge]
+    syncEdges = dataEdges <> antiEdges
+
+    -- Longest-path depth over the ordering edges (see 'Fragr.Snapshot.level').
+    -- Edges point backward, so one sweep in registration order sees every
+    -- predecessor's level already settled.
+    predecessors :: IntMap [Int]
+    predecessors = IntMap.fromListWith (<>) do
+      e <- syncEdges
+      pure (e.to, [e.from])
+
+    levelMap :: IntMap Int
+    levelMap = foldl' step IntMap.empty executing
+      where
+        step acc p = IntMap.insert p.passId (maximum (0 : map depth preds)) acc
+          where
+            preds = IntMap.findWithDefault [] p.passId predecessors
+            depth q = 1 + IntMap.findWithDefault 0 q acc
+
+    -- The anti-edges as a per-reader adjacency, for writers that want the
+    -- ordering the dataflow does not draw.
+    antiAfterMap :: IntMap [Int]
+    antiAfterMap =
+      IntSet.toList <$> IntMap.fromListWith (<>) do
+        e <- antiEdges
+        pure (e.from, IntSet.singleton e.to)
+
+    -- Cross-queue waits, deduplicated by a per-(consumer,producer)-queue
+    -- watermark: because passes on a queue run in order and each waits
+    -- before running, a value already awaited by an earlier same-queue pass
+    -- is implied and dropped (transitive reduction). Only the wait is
+    -- implied, not its scope: a backend derives stage masks from covers, so
+    -- a dropped wait's accesses migrate to the kept wait behind the
+    -- watermark.
+    foreignNeed :: IntMap (IntMap (Word64, Set Access))
+    foreignNeed =
+      IntMap.fromListWith (IntMap.unionWith merge) (edgeNeed <> siblingNeed)
+      where
+        edgeNeed = do
+          e <- syncEdges
+          let
+            QueueId pq = qOf e.from
+            QueueId cq = qOf e.to
+          guard (pq /= cq)
+          pure (e.to, IntMap.singleton pq (posOf IntMap.! e.from, e.dstAccess))
+        merge (v1, a1) (v2, a2) = (max v1 v2, a1 <> a2)
+
+    waitsMap :: IntMap [Wait]
+    waitsMap = snd (foldl' go (Map.empty, IntMap.empty) executing)
+      where
+        go acc@(wm, out) c =
+          case IntMap.lookup c.passId foreignNeed of
+            Nothing -> acc
+            Just needs ->
+              let
+                QueueId cq = c.queue
+                ((wm', out'), ws) = foldl' (perPq cq c.passId) ((wm, out), []) (IntMap.toAscList needs)
+              in
+                (wm', IntMap.insert c.passId (reverse ws) out')
+        perPq cq pid ((wm, ws'), ws) (pq, (v, accs)) =
+          case Map.lookup (cq, pq) wm of
+            Just (cur, keeper)
+              | v <= cur ->
+                  ((wm, IntMap.adjust (map (widen pq accs)) keeper ws'), ws)
+            _ ->
+              ( (Map.insert (cq, pq) (v, pid) wm, ws')
+              , Wait{queue = QueueId pq, value = v, covers = Set.toAscList accs} : ws
+              )
+        widen pq accs w
+          | w.queue == QueueId pq =
+              Wait{queue = w.queue, value = w.value, covers = Set.toAscList (Set.fromList w.covers <> accs)}
+          | otherwise = w
+
+    -- Same-queue dependencies with at least one pass in between get a
+    -- split-barrier event pair; adjacent ones rely on a plain barrier. Each
+    -- endpoint covers its own pass's accesses (barrier scopes).
+    gappedByPair :: Map.Map (Int, Int) (Set Access, Set Access)
+    gappedByPair = Map.fromListWith (<>) do
+      e <- syncEdges
+      guard (qOf e.from == qOf e.to)
+      guard (posOf IntMap.! e.to - posOf IntMap.! e.from > 1)
+      pure ((e.from, e.to), (e.srcAccess, e.dstAccess))
+    eventOf = zip (Map.toAscList gappedByPair) (map EventId [0 ..])
+    eventsBy pick = IntMap.fromListWith (<>) do
+      ((pair, ends), eid) <- eventOf
+      let (pass, accs) = pick pair ends
+      pure (pass, Set.singleton SyncEvent{event = eid, covers = Set.toAscList accs})
+    signalEventsMap = eventsBy \(pid, _) (src, _) -> (pid, src)
+    waitEventsMap = eventsBy \(_, c) (_, dst) -> (c, dst)
+
+    -- Cross-queue ownership transfer: the producer releases the output to
+    -- the consumer's queue, the consumer acquires it from the producer's,
+    -- both under the consuming access's flags. Data edges only — anti-edges
+    -- carry no payload.
+    --
+    -- A rename consumes the old version through its implicit flagless read;
+    -- its transfer carries the renaming write's access instead — the state
+    -- the consumer actually uses the resource in, and the only flags the
+    -- release / acquire hooks could ever see.
+    renameAccess c = \case
+      Access{handle = Handle i, flags = Nothing}
+        | Just (w, next) <- IntMap.lookup i renamerOf
+        , w == c
+        , a' : _ <- Set.toAscList (IntMap.findWithDefault Set.empty next writesByNode) ->
+            a'
+      a -> a
+    famOf :: QueueId -> Maybe FamilyId
+    famOf (QueueId q) = IntMap.lookup q families
+
+    -- Ownership moves per family where the partition says so: consumers
+    -- whose queue has a family are grouped per (node, family) and only the
+    -- group's first-registered consumer — the primary acquirer — carries
+    -- the transfer, under its own accesses. Consumers outside the
+    -- partition (e.g. a host queue) keep one transfer per consuming queue.
+    (legacyRows, familyRows) = partitionEithers do
+      e <- dataEdges
+      guard (qOf e.from /= qOf e.to)
+      a <- Set.toAscList e.dstAccess
+      pure case famOf (qOf e.to) of
+        Nothing -> Left ((accessId a, qOf e.to), IntMap.singleton e.to (Set.singleton a))
+        Just fam -> Right ((accessId a, fam), IntMap.singleton e.to (Set.singleton a))
+    -- Per (node, family) / (node, queue) outside the partition: each
+    -- consuming pass's accesses.
+    familyMembers :: Map.Map (Int, FamilyId) (IntMap (Set Access))
+    familyMembers = Map.fromListWith (IntMap.unionWith (<>)) familyRows
+    legacyMembers :: Map.Map (Int, QueueId) (IntMap (Set Access))
+    legacyMembers = Map.fromListWith (IntMap.unionWith (<>)) legacyRows
+    -- The producer and the group's first-registered consumer — the primary
+    -- acquirer — with its accesses; a version released once must be
+    -- acquired once.
+    primaryOf node members = case IntMap.findMin members of
+      (c, accs) -> (nodeProducer IntMap.! node, c, accs)
+    familyGroups :: Map.Map (Int, FamilyId) (Int, Int, Set Access)
+    familyGroups = Map.mapWithKey (\(node, _fam) -> primaryOf node) familyMembers
+    legacyGroups :: Map.Map (Int, QueueId) (Int, Int, Set Access)
+    legacyGroups = Map.mapWithKey (\(node, _q) -> primaryOf node) legacyMembers
+    -- The sibling-queue acquire guard: the acquire barrier is recorded on
+    -- the primary acquirer's queue, so a same-family consumer on another
+    -- queue must also wait for the acquiring pass, not just the producer.
+    -- Only where a real acquire happens — a hand-off within the producer's
+    -- own family or of a shared resource transitions producer-side, which
+    -- the data-edge waits already order.
+    siblingNeed :: [(Int, IntMap (Word64, Set Access))]
+    siblingNeed = do
+      ((node, fam), members) <- Map.toAscList familyMembers
+      guard (entryOfNode node `Set.notMember` sharedEntries)
+      guard (famOf (qOf (nodeProducer IntMap.! node)) /= Just fam)
+      let
+        (primary, _) = IntMap.findMin members
+        QueueId pq = qOf primary
+      (c, accs) <- IntMap.toAscList (IntMap.delete primary members)
+      guard (qOf c /= qOf primary)
+      pure (c, IntMap.singleton pq (posOf IntMap.! primary, accs))
+    transferPairs = do
+      (pid, primary, accs) <- Map.elems legacyGroups <> Map.elems familyGroups
+      a <- Set.toAscList accs
+      pure (pid, primary, renameAccess primary a)
+    releasesMap =
+      IntMap.fromListWith (<>) do
+        (pid, c, Access h f) <- transferPairs
+        pure (pid, Set.singleton Transfer{handle = h, peer = qOf c, flags = f})
+    acquiresMap =
+      IntMap.fromListWith (<>) do
+        (pid, c, Access h f) <- transferPairs
+        pure (c, Set.singleton Transfer{handle = h, peer = qOf pid, flags = f})
+
+    -- One version consumed on two distinct families: a single owner cannot
+    -- be released twice. Shared entries carry no ownership and are exempt.
+    nodeFamilies :: IntMap (Map.Map FamilyId Int)
+    nodeFamilies = IntMap.fromListWith (<>) do
+      ((node, fam), (_pid, primary, _accs)) <- Map.toAscList familyGroups
+      pure (node, Map.singleton fam primary)
+    ownershipViolations = do
+      (node, fams) <- IntMap.toAscList nodeFamilies
+      guard (Map.size fams > 1)
+      guard (entryOfNode node `Set.notMember` sharedEntries)
+      pure
+        ( (entries `Seq.index` entryOfNode node).name
+        , do
+            (fam, c) <- Map.toAscList fams
+            pure ((passes `Seq.index` c).name, fam)
+        )
+
+    syncMap = IntMap.fromList do
+      p <- executing
+      let
+        setAt :: IntMap (Set a) -> [a]
+        setAt m = Set.toAscList (IntMap.findWithDefault Set.empty p.passId m)
+      pure
+        ( p.passId
+        , PassSync
+            { passId = p.passId
+            , name = p.name
+            , queue = p.queue
+            , waits = IntMap.findWithDefault [] p.passId waitsMap
+            , signal = posOf IntMap.! p.passId
+            , waitEvents = setAt waitEventsMap
+            , signalEvents = setAt signalEventsMap
+            , acquires = setAt acquiresMap
+            , releases = setAt releasesMap
+            }
+        )
+
+    -- Per-entry deferred-reclamation requirements (transients only — the
+    -- graph never reclaims imports): the last executing user position on
+    -- each queue that touches the entry.
+    entryUses = do
+      p <- executing
+      h <- touchedNodes p
+      let e = entryOfNode h
+      guard (e `Set.notMember` importedEntries)
+      pure (e, p)
+    entryRetireMap =
+      IntMap.map
+        ( \m -> do
+            (q, v) <- Map.toAscList m
+            pure (QueueId q, v)
+        )
+        ( IntMap.fromListWith (Map.unionWith max) do
+            (e, p) <- entryUses
+            let QueueId q = p.queue
+            pure (e, Map.singleton q (posOf IntMap.! p.passId))
+        )
+    entryLastMap = IntMap.fromListWith max do
+      (e, p) <- entryUses
+      pure (e, p.passId)
+    retireAfterMap = IntMap.fromListWith (flip (<>)) do
+      (e, pid) <- IntMap.toList entryLastMap
+      pure (pid, [e])
diff --git a/src/Fragr/Error.hs b/src/Fragr/Error.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Error.hs
@@ -0,0 +1,39 @@
+-- | The library's fatal error type.
+module Fragr.Error
+  ( FragrError (..)
+  ) where
+
+import Control.Exception (Exception)
+import Data.Text (Text)
+import Type.Reflection (SomeTypeRep)
+
+import Fragr.Types (FamilyId, SomeHandle)
+
+{- |
+API misuse is a programmer error and is reported by throwing this
+exception. The 'Text' fields name the operation that detected the misuse.
+-}
+data FragrError
+  = -- | access through a handle superseded by a later 'Fragr.Builder.write'
+    StaleHandle Text SomeHandle
+  | -- | 'Fragr.Builder.read' of a handle the same pass creates or writes
+    ReadsOwnOutput SomeHandle
+  | -- | the entry holds the first type, the caller requested the second
+    TypeMismatch Text SomeTypeRep SomeTypeRep
+  | -- | execution-time access to a handle the pass (named last) never declared
+    Undeclared Text SomeHandle Text
+  | HandleOutOfRange Int
+  | ResourceIdOutOfRange Int
+  | -- | execution attempted before 'Fragr.Compile.compile'
+    NotCompiled Text
+  | -- | 'Fragr.Execute.executeQueued' without a 'Fragr.Recycle.RecycleQueue', but the named transient needs deferred reclamation
+    RecycleQueueRequired Text
+  | -- | the named alias-group member is read (by the pass named last) before anything writes it ('Fragr.Compile.validateAliasGroups')
+    AliasedReadBeforeWrite Text Text
+  | -- | one version of the named resource is consumed on two distinct queue families ('Fragr.Compile.compileWith'), listed with each family's first consumer; a single owner cannot be released twice — 'Fragr.Graph.markShared' resources are exempt
+    ReleasedToTwoFamilies Text [(Text, FamilyId)]
+  | -- | a broken library invariant, not an API misuse
+    InternalInvariant Text
+  deriving stock (Show)
+
+instance Exception FragrError
diff --git a/src/Fragr/Exec.hs b/src/Fragr/Exec.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Exec.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+
+-- | The pass-execution monad and its resource accessors.
+module Fragr.Exec
+  ( Exec
+  , askCtx
+  , get
+  , getDesc
+  ) where
+
+import Control.Exception (throwIO)
+import Control.Monad (unless)
+import Control.Monad.Trans.Reader (ReaderT (..), asks)
+import Data.IORef (readIORef)
+import Data.IntSet qualified as IntSet
+import Data.Text (Text)
+
+import Fragr.Error (FragrError (..))
+import Fragr.Graph (PassNode (..), ResourceEntry, Resources (..), castEntry, entryOf)
+import Fragr.Resource (Resource (..))
+import Fragr.Types (Handle, SomeHandle (..), handleId)
+
+{- |
+The pass-execution monad: the exec argument of 'Fragr.Builder.addPass'.
+A 'MonadIO' — recording commands is what the callback is for. A reader
+over 'Resources', carrying:
+
+* the executing pass's resource accessor — 'get' and 'getDesc' are
+  restricted to the handles the pass declared;
+* the context given to 'Fragr.Execute.execute' ('askCtx').
+
+'MonadFail' delegates to 'IO', so failable pattern binds on 'get' results
+behave like they would in a plain IO callback.
+-}
+type Exec ctx alloc = ReaderT (Resources ctx alloc) IO
+
+{- | The context 'Fragr.Execute.execute' / 'Fragr.Execute.executeQueued'
+was given for this frame.
+-}
+{-# INLINE askCtx #-}
+askCtx :: Exec ctx alloc ctx
+askCtx = asks (.ctx)
+
+{- |
+The live resource object behind the handle (after 'createResource' has run
+for transients; the very object given to 'importResource' for imports).
+
+Fatal error: the pass never declared the handle (a stale handle is never
+in the declarations — that is the "obsolete handle" error).
+-}
+{-# INLINEABLE get #-}
+get
+  :: forall r ctx alloc
+   . (Resource r)
+  => Handle r
+  -> Exec ctx alloc r
+get h = ReaderT \res -> do
+  entry <- declaredEntry "get" res h
+  (_, objRef) <- castEntry @r "get" entry
+  readIORef objRef >>= \case
+    Nothing -> throwIO $ InternalInvariant "resource not materialized"
+    Just obj -> pure obj
+
+{- | Like 'Fragr.Graph.getDescriptor', but through the pass accessor:
+restricted to handles the pass declared.
+-}
+{-# INLINEABLE getDesc #-}
+getDesc
+  :: forall r ctx alloc
+   . (Resource r)
+  => Handle r
+  -> Exec ctx alloc (Desc r)
+getDesc h = ReaderT \res -> do
+  entry <- declaredEntry "getDesc" res h
+  (desc, _) <- castEntry @r "getDesc" entry
+  pure desc
+
+declaredEntry :: Text -> Resources ctx alloc -> Handle r -> IO (ResourceEntry ctx alloc)
+declaredEntry who res h = do
+  unless (handleId h `IntSet.member` res.pass.declared) $
+    throwIO $
+      Undeclared who (SomeHandle h) res.pass.name
+  entryOf res.graph h
diff --git a/src/Fragr/Execute.hs b/src/Fragr/Execute.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Execute.hs
@@ -0,0 +1,213 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE TypeFamilies #-}
+
+{-|
+The execute phase.
+
+The single-queue fast path and the schedule-driven multi-queue path with
+its 'QueueBackend' seam.
+-}
+module Fragr.Execute
+  ( execute
+  , executeQueued
+  , QueueBackend (..)
+  , executingQueues
+  ) where
+
+import Control.Exception (throwIO)
+import Control.Monad (void, when)
+import Control.Monad.IO.Class (MonadIO (..))
+import Data.Coerce (coerce)
+import Data.Foldable (for_)
+import Data.IORef (readIORef, writeIORef)
+import Data.IntMap.Strict qualified as IntMap
+import Data.IntSet qualified as IntSet
+import Data.Word (Word64)
+import Type.Reflection
+
+import Fragr.Compile (canExecute)
+import Fragr.Error (FragrError (..))
+import Fragr.Graph (FrameGraph (..), PassNode (..), ResourceEntry (..), Resources (..), SomeEntry (..), entryAt, entryOf, requireCompiled)
+import Fragr.Recycle (RecycleQueue, collect, mkRetireItem, retireItem)
+import Fragr.Resource (Access (..), Resource (..))
+import Fragr.Sync (Compiled (..), PassSync (..), Transfer (..))
+import Fragr.Types (Handle, QueueId (..), SomeHandle (..))
+
+{- |
+Walk the passes in registration order, skipping culled ones. Per
+surviving pass:
+
+1. materialize the transients it creates;
+2. run the 'preRead' / 'preWrite' hooks for accesses with flags, then
+   the 'Fragr.Graph.addPreExec' flushes;
+3. invoke its execution callback;
+4. fire the 'Fragr.Graph.addPostExec' flushes;
+5. destroy every transient whose last executing user it is.
+
+@ctx@ is forwarded to execution callbacks and hooks, @alloc@ to
+'createResource' / 'destroyResource'.
+-}
+{-# INLINE execute #-}
+execute :: (MonadIO m) => FrameGraph ctx alloc -> ctx -> alloc -> m ()
+execute g ctx alloc = liftIO do
+  compiled <- requireCompiled g "execute"
+  passes <- readIORef g.passesRef
+  for_ passes \p ->
+    when (canExecute compiled.passRefs p) do
+      runPass g ctx alloc Nothing p
+      for_ (IntMap.findWithDefault [] p.passId compiled.retireAfter) \e ->
+        entryAt g e >>= release alloc
+
+{- |
+The seam through which 'executeQueued' hands the schedule to the
+application. The library computes 'PassSync' values and calls these back;
+it never interprets a queue, timeline value or event itself.
+-}
+data QueueBackend = QueueBackend
+  { beforePass :: PassSync -> IO ()
+  {- ^ before a pass runs: wait the listed timeline values and events, and
+  acquire ownership of the listed resources
+  -}
+  , afterPass :: PassSync -> IO ()
+  {- ^ after a pass runs: release the listed resources, signal the listed
+  events, then signal this pass's timeline value
+  -}
+  , invoke :: PassSync -> IO () -> IO ()
+  {- ^ invocation control over one pass's whole step ('beforePass', the
+  hook/run sequence, 'afterPass'): run it now (@\_ body -> body@, a device
+  queue recording commands) or stash it to run later (a host queue executed
+  after the submits, against real timeline values).
+
+  Deferring moves the pass's 'createResource' calls with it, so a deferred
+  queue must produce nothing an inline-invoked pass consumes — the consumer
+  runs first and finds the resource unmaterialized. Reclamation is safe by
+  construction: it goes through the 'RecycleQueue', whose 'completed' values
+  gate the release behind the deferred pass's own timeline signal.
+  -}
+  , completed :: IO [(QueueId, Word64)]
+  {- ^ the currently-reached timeline value per queue, consulted to decide
+  what the recycle queue may reclaim
+  -}
+  }
+
+{- |
+Multi-queue execution: 'execute' driving the compiled schedule through
+the 'QueueBackend' instead of destroying transients inline. Per surviving
+pass:
+
+1. hand its step to 'invoke' — 'beforePass', then materialize plus the
+   full hook sequence of 'runPass' (acquire / release sides included),
+   then 'afterPass';
+2. retire any transient whose last executing user this pass is;
+3. 'collect' the recycle queue.
+
+An import-only graph (every resource owned outside the graph) never retires
+anything and may pass 'Nothing' for the recycle queue; a graph that does
+need to reclaim a transient then fails upfront with 'RecycleQueueRequired'.
+-}
+{-# INLINE executeQueued #-}
+executeQueued
+  :: (MonadIO m)
+  => FrameGraph ctx alloc
+  -> QueueBackend
+  -> Maybe RecycleQueue
+  -> ctx
+  -> alloc
+  -> m ()
+executeQueued g backend mrq ctx alloc = liftIO do
+  compiled <- requireCompiled g "executeQueued"
+  -- The retire schedule holds transients only, so any entry at all
+  -- demands a recycle queue.
+  case (mrq, concat (IntMap.elems compiled.retireAfter)) of
+    (Nothing, e : _) -> do
+      entry <- entryAt g e
+      throwIO $ RecycleQueueRequired entry.name
+    _ -> pure ()
+  passes <- readIORef g.passesRef
+  for_ passes \p ->
+    when (canExecute compiled.passRefs p) do
+      psync <- case IntMap.lookup p.passId compiled.passSync of
+        Nothing -> throwIO $ InternalInvariant "no schedule for an executing pass"
+        Just s -> pure s
+      backend.invoke psync do
+        backend.beforePass psync
+        runPass g ctx alloc (Just psync) p
+        backend.afterPass psync
+      for_ mrq \rq -> do
+        for_ (IntMap.findWithDefault [] p.passId compiled.retireAfter) \e -> do
+          entry <- entryAt g e
+          let reqs = IntMap.findWithDefault [] e compiled.entryRetire
+          item <- mkRetireItem e reqs (release alloc entry)
+          retireItem rq item
+        backend.completed >>= void . collect rq
+  for_ mrq \rq -> backend.completed >>= void . collect rq
+
+{- |
+The queues with at least one executing pass on the compiled schedule,
+ascending. Backends size per-queue state (command buffers, timelines) from
+it. Fatal before 'Fragr.Compile.compile'.
+-}
+{-# INLINEABLE executingQueues #-}
+executingQueues :: (MonadIO m) => FrameGraph ctx alloc -> m [QueueId]
+executingQueues g = liftIO do
+  c <- requireCompiled g "executingQueues"
+  pure $ coerce . IntSet.toAscList . IntSet.fromList . coerce $ (map (.queue) (IntMap.elems c.passSync))
+
+{- | The per-pass body shared by 'execute' and 'executeQueued': materialize
+the pass's creates, fire the acquire / read / write hooks for accesses with
+flags, fire the 'Fragr.Graph.addPreExec' flushes, run the callback, fire the
+release hooks, then the 'Fragr.Graph.addPostExec' flushes. The acquire and
+release sides exist only under a schedule ('executeQueued').
+-}
+runPass :: forall ctx alloc. FrameGraph ctx alloc -> ctx -> alloc -> Maybe PassSync -> PassNode ctx alloc -> IO ()
+runPass g ctx alloc msync p = do
+  for_ p.creates \(SomeHandle h) -> do
+    entry <- entryOf g h
+    when entry.imported $
+      throwIO $
+        InternalInvariant "create on an imported entry"
+    materialize alloc entry
+  for_ (maybe [] (.acquires) msync) \(Transfer h peer flags) -> fire (\hh d fl -> preAcquire hh d fl peer) h flags
+  for_ p.reads \(Access h flags) -> fire preRead h flags
+  for_ p.writes \(Access h flags) -> fire preWrite h flags
+  preExecs <- readIORef g.preExecRef
+  for_ preExecs ($ ctx)
+  p.run Resources{graph = g, pass = p, ctx}
+  for_ (maybe [] (.releases) msync) \(Transfer h peer flags) -> fire (\hh d fl -> preRelease hh d fl peer) h flags
+  postExecs <- readIORef g.postExecRef
+  for_ postExecs ($ ctx)
+  where
+    fire
+      :: forall r
+       . (Resource r)
+      => (forall s. (Resource s, Alloc s ~ alloc, Ctx s ~ ctx) => Handle s -> Desc s -> Flags s -> Ctx s -> s -> IO ())
+      -> Handle r
+      -> Maybe (Flags r)
+      -> IO ()
+    fire hook h mflags = for_ mflags run
+      where
+        run :: Flags r -> IO ()
+        run flags = do
+          entry <- entryOf g h
+          case entry.payload of
+            SomeEntry rep desc objRef -> case eqTypeRep rep (typeRep @r) of
+              Nothing -> throwIO $ TypeMismatch "hook" (SomeTypeRep rep) (SomeTypeRep (typeRep @r))
+              Just HRefl ->
+                readIORef objRef >>= \case
+                  Nothing -> throwIO $ InternalInvariant "hook before create"
+                  Just obj -> hook h desc flags ctx obj
+
+materialize :: alloc -> ResourceEntry ctx alloc -> IO ()
+materialize alloc entry = case entry.payload of
+  SomeEntry _ desc objRef -> do
+    obj <- createResource desc alloc
+    writeIORef objRef (Just obj)
+
+release :: alloc -> ResourceEntry ctx alloc -> IO ()
+release alloc entry = case entry.payload of
+  SomeEntry _ desc objRef ->
+    readIORef objRef >>= \case
+      Nothing -> throwIO $ InternalInvariant "destroy before create"
+      Just obj -> do
+        destroyResource desc alloc obj
+        writeIORef objRef Nothing
diff --git a/src/Fragr/Graph.hs b/src/Fragr/Graph.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Graph.hs
@@ -0,0 +1,386 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE TypeFamilies #-}
+
+{-|
+The mutable graph object and its bookkeeping.
+
+Pass / resource records and the graph-level operations that need no pass
+context. Everything public here is re-exported through "Fragr"; the
+bookkeeping internals carry no stability guarantees when imported
+directly.
+-}
+module Fragr.Graph
+  ( FrameGraph (..)
+  , newFrameGraph
+  , PassNode (..)
+  , ResourceNode (..)
+  , ResourceEntry (..)
+  , SomeEntry (..)
+  , Resources (..)
+  , importResource
+  , importScratch
+  , markShared
+  , markObserved
+  , addPreExec
+  , addPostExec
+  , isValid
+  , getDescriptor
+  , appendEntry
+  , appendNode
+  , nodeAt
+  , entryAt
+  , entryOf
+  , producedNodes
+  , touchedNodes
+  , requireCompiled
+  , assertValid
+  , castEntry
+  , describeEntry
+  ) where
+
+import Type.Reflection
+
+import Control.Exception (throwIO)
+import Control.Monad (unless)
+import Control.Monad.IO.Class (MonadIO (..))
+import Data.IORef
+import Data.IntSet (IntSet)
+import Data.Sequence (Seq, (|>))
+import Data.Sequence qualified as Seq
+import Data.Text (Text)
+
+import Fragr.Error (FragrError (..))
+import Fragr.Resource (Access, Resource (..), accessId)
+import Fragr.Sync (Compiled)
+import Fragr.Types (Handle (..), QueueId, SomeHandle (..), someHandleId)
+
+{- |
+The frame graph. @ctx@ and @alloc@ are the opaque user values accepted by
+'execute'; every resource used with the graph must agree on them (via its
+'Ctx' and 'Alloc' associated types).
+-}
+data FrameGraph ctx alloc = FrameGraph
+  { passesRef :: IORef (Seq (PassNode ctx alloc))
+  , nodesRef :: IORef (Seq ResourceNode)
+  , entriesRef :: IORef (Seq (ResourceEntry ctx alloc))
+  , compiledRef :: IORef (Maybe Compiled)
+  , preExecRef :: IORef (Seq (ctx -> IO ()))
+  , postExecRef :: IORef (Seq (ctx -> IO ()))
+  }
+
+{-# INLINEABLE newFrameGraph #-}
+-- XXX: The @forall@ keeps @ctx@ and @alloc@ first for type applications;
+-- the other type-applied entry points ('create', 'importResource', 'get',
+-- 'getDesc', 'getDescriptor') fix @r@ first the same way.
+newFrameGraph :: forall ctx alloc m. (MonadIO m) => m (FrameGraph ctx alloc)
+newFrameGraph = liftIO do
+  FrameGraph
+    <$> newIORef mempty
+    <*> newIORef mempty
+    <*> newIORef mempty
+    <*> newIORef Nothing
+    <*> newIORef mempty
+    <*> newIORef mempty
+
+-- | One registered pass.
+data PassNode ctx alloc = PassNode
+  { passId :: Int
+  , name :: Text
+  , creates :: [SomeHandle]
+  , reads :: [Access]
+  , writes :: [Access]
+  , declared :: IntSet
+  {- ^ every declared handle (creates, reads, writes): the membership
+  index behind execution-time access checks
+  -}
+  , sideEffect :: Bool
+  , queue :: QueueId
+  , run :: Resources ctx alloc -> IO ()
+  }
+
+-- | One version of a resource. Immutable after creation.
+data ResourceNode = ResourceNode
+  { nodeId :: Int
+  , resourceId :: Int
+  , version :: Int
+  }
+
+-- | One distinct resource: identity, current version, type-erased payload.
+data ResourceEntry ctx alloc = ResourceEntry
+  { entryId :: Int
+  , name :: Text
+  , imported :: Bool
+  , observed :: Bool
+  {- ^ Writes are externally visible ('importResource'): writers become
+  side effects, immune to culling. 'importScratch' and created
+  transients are only read through the graph, so demand decides.
+  -}
+  , shared :: Bool
+  {- ^ Exempt from single-owner validation ('markShared'): the allocation
+  tolerates concurrent access from several queue families at once, so
+  consuming one version on two families is not an error for it. Ownership
+  itself is never stored — each version's owner is implied by its
+  producing pass, and transfers move it along the data edges.
+  -}
+  , versionRef :: IORef Int
+  , payload :: SomeEntry ctx alloc
+  }
+
+{- | Type-erased resource object with its descriptor. The object slot is
+'Nothing' for a transient that has not been materialized (yet, or
+anymore).
+-}
+data SomeEntry ctx alloc where
+  SomeEntry
+    :: (Resource r, Alloc r ~ alloc, Ctx r ~ ctx)
+    => TypeRep r
+    -> Desc r
+    -> IORef (Maybe r)
+    -> SomeEntry ctx alloc
+
+initialVersion :: Int
+initialVersion = 1
+
+{- |
+The environment 'Exec' reads: the executing pass, its graph, and the
+frame context. Resource access is restricted to the handles the pass
+declared via create, read or write.
+-}
+data Resources ctx alloc = Resources
+  { graph :: FrameGraph ctx alloc
+  , pass :: PassNode ctx alloc
+  , ctx :: ctx
+  }
+
+{- |
+Import an externally-owned, already-constructed resource. The graph never
+'createResource's nor 'destroyResource's it. Not tied to any pass.
+
+Its contents are externally observable, so every pass writing it becomes a
+side effect, immune to culling; import scratch-like targets with
+'importScratch' instead.
+-}
+{-# INLINEABLE importResource #-}
+importResource
+  :: forall r ctx alloc m
+   . (Resource r, Alloc r ~ alloc, Ctx r ~ ctx, MonadIO m)
+  => FrameGraph ctx alloc
+  -> Text
+  -> Desc r
+  -> r
+  -> m (Handle r)
+importResource g resName desc obj = liftIO do
+  objRef <- newIORef (Just obj)
+  appendEntry g resName True True desc objRef
+
+{- |
+'importResource' for a target nothing observes from outside the graph: the
+object and its memory are still externally owned, but its contents matter
+only to passes of this graph, so writers stay subject to demand culling
+like any transient's. Mark the passes that feed a between-graphs consumer
+'setSideEffect' explicitly.
+-}
+{-# INLINEABLE importScratch #-}
+importScratch
+  :: forall r ctx alloc m
+   . (Resource r, Alloc r ~ alloc, Ctx r ~ ctx, MonadIO m)
+  => FrameGraph ctx alloc
+  -> Text
+  -> Desc r
+  -> r
+  -> m (Handle r)
+importScratch g resName desc obj = liftIO do
+  objRef <- newIORef (Just obj)
+  appendEntry g resName True False desc objRef
+
+{- |
+Mark the resource behind the handle as tolerating concurrent access from
+several queue families at once — Vulkan @CONCURRENT@ sharing — so
+'Fragr.Compile.compileWith' skips its single-owner check
+('Fragr.Error.ReleasedToTwoFamilies'). Its transfers are still derived:
+a backend melts them into plain state transitions, but the hooks must
+fire.
+
+Imports read this off the object ('Fragr.Resource.isShared'); marking by
+hand is for created transients, whose object does not exist yet.
+-}
+{-# INLINEABLE markShared #-}
+markShared :: (MonadIO m) => FrameGraph ctx alloc -> Handle r -> m ()
+markShared g h = liftIO do
+  node <- nodeAt g h
+  modifyIORef' g.entriesRef (Seq.adjust' (\e -> e{shared = True}) node.resourceId)
+  writeIORef g.compiledRef Nothing
+
+{- |
+Make writes to the resource behind the handle externally observable, as if
+imported through 'importResource': later writers become side effects.
+Not exported through "Fragr" — 'Fragr.Builder.importOwned' flips its entry
+/after/ the synthetic pass, keeping that pass itself cullable.
+-}
+markObserved :: (MonadIO m) => FrameGraph ctx alloc -> Handle r -> m ()
+markObserved g h = liftIO do
+  node <- nodeAt g h
+  modifyIORef' g.entriesRef (Seq.adjust' (\e -> e{observed = True}) node.resourceId)
+
+{- |
+Install a per-pass flush point: an action fired by 'execute' and
+'executeQueued' after every executing pass's hooks ('preAcquire',
+'preRead', 'preWrite') and before its execution callback. Hooks that
+accumulate work into @ctx@ (e.g. image barriers to batch into one command)
+emit it here. Every installed action fires for every executing pass, in
+installation order — a library adapter and the application can hook the
+same graph. Installation is append-only: on a graph kept across frames,
+install during setup, not per frame.
+-}
+{-# INLINE addPreExec #-}
+addPreExec :: (MonadIO m) => FrameGraph ctx alloc -> (ctx -> IO ()) -> m ()
+addPreExec g f = liftIO $ modifyIORef' g.preExecRef (|> f)
+
+{- |
+The post-pass counterpart of 'addPreExec': an action fired after every
+executing pass's execution callback and its 'preRelease' hooks, so a pass
+releasing several resources to another queue can batch the release
+barriers its hooks accumulated into one command.
+-}
+{-# INLINE addPostExec #-}
+addPostExec :: (MonadIO m) => FrameGraph ctx alloc -> (ctx -> IO ()) -> m ()
+addPostExec g f = liftIO $ modifyIORef' g.postExecRef (|> f)
+
+{- |
+True iff the handle names the /latest/ version of its resource. A handle
+out of range is a fatal error, not 'False'.
+-}
+{-# INLINEABLE isValid #-}
+isValid :: (MonadIO m) => FrameGraph ctx alloc -> Handle r -> m Bool
+isValid g h = liftIO do
+  node <- nodeAt g h
+  entry <- entryAt g node.resourceId
+  current <- readIORef entry.versionRef
+  pure (node.version == current)
+
+-- | The descriptor of the resource behind the handle.
+{-# INLINEABLE getDescriptor #-}
+getDescriptor
+  :: forall r ctx alloc m
+   . (Resource r, MonadIO m)
+  => FrameGraph ctx alloc
+  -> Handle r
+  -> m (Desc r)
+getDescriptor g h = liftIO do
+  entry <- entryOf g h
+  (desc, _) <- castEntry @r "getDescriptor" entry
+  pure desc
+
+appendEntry
+  :: forall r ctx alloc
+   . (Resource r, Alloc r ~ alloc, Ctx r ~ ctx)
+  => FrameGraph ctx alloc
+  -> Text
+  -> Bool
+  -> Bool
+  -> Desc r
+  -> IORef (Maybe r)
+  -> IO (Handle r)
+appendEntry g resName isImported isObserved desc objRef = do
+  entries <- readIORef g.entriesRef
+  versionRef <- newIORef initialVersion
+  -- An import's object is already here and knows its sharing; a created
+  -- transient has none yet ('markShared' is its channel).
+  obj <- readIORef objRef
+  let entry =
+        ResourceEntry
+          { entryId = Seq.length entries
+          , name = resName
+          , imported = isImported
+          , observed = isObserved
+          , shared = maybe False isShared obj
+          , versionRef
+          , payload = SomeEntry (typeRep @r) desc objRef
+          }
+  writeIORef g.entriesRef (entries |> entry)
+  appendNode g entry.entryId initialVersion
+
+-- Polymorphic in @r@: the caller pins the minted handle's resource type
+-- (the entry's for 'appendEntry', the renamed handle's for a write).
+appendNode :: FrameGraph ctx alloc -> Int -> Int -> IO (Handle r)
+appendNode g rid ver = do
+  nodes <- readIORef g.nodesRef
+  let node =
+        ResourceNode
+          { nodeId = Seq.length nodes
+          , resourceId = rid
+          , version = ver
+          }
+  writeIORef g.nodesRef (nodes |> node)
+  -- Like registering a pass ('addPass') or 'markShared', appending a node
+  -- invalidates any prior compilation.
+  writeIORef g.compiledRef Nothing
+  pure (Handle node.nodeId)
+
+{-# INLINEABLE nodeAt #-}
+nodeAt :: FrameGraph ctx alloc -> Handle r -> IO ResourceNode
+nodeAt g (Handle i) = do
+  nodes <- readIORef g.nodesRef
+  case Seq.lookup i nodes of
+    Nothing -> throwIO $ HandleOutOfRange i
+    Just node -> pure node
+
+{-# INLINEABLE entryAt #-}
+entryAt :: FrameGraph ctx alloc -> Int -> IO (ResourceEntry ctx alloc)
+entryAt g i = do
+  entries <- readIORef g.entriesRef
+  case Seq.lookup i entries of
+    Nothing -> throwIO $ ResourceIdOutOfRange i
+    Just entry -> pure entry
+
+-- | The compiled schedule, or 'NotCompiled' naming the caller.
+{-# INLINEABLE requireCompiled #-}
+requireCompiled :: FrameGraph ctx alloc -> Text -> IO Compiled
+requireCompiled g who =
+  readIORef g.compiledRef >>= \case
+    Nothing -> throwIO $ NotCompiled who
+    Just c -> pure c
+
+{-# INLINEABLE entryOf #-}
+entryOf :: FrameGraph ctx alloc -> Handle r -> IO (ResourceEntry ctx alloc)
+entryOf g h = do
+  node <- nodeAt g h
+  entryAt g node.resourceId
+
+{- | The node ids the pass produces: one per create and per write.
+
+Culling refcounts the pass by these, and 'Fragr.Builder.finalize' finds a
+handle's producer through them.
+-}
+producedNodes :: PassNode ctx alloc -> [Int]
+producedNodes p = map someHandleId p.creates <> map accessId p.writes
+
+-- | The node ids the pass touches at all: 'producedNodes' plus its reads.
+touchedNodes :: PassNode ctx alloc -> [Int]
+touchedNodes p = producedNodes p <> map accessId p.reads
+
+{-# INLINEABLE assertValid #-}
+assertValid :: Text -> FrameGraph ctx alloc -> Handle r -> IO ()
+assertValid who g h = do
+  ok <- isValid g h
+  unless ok $
+    throwIO $
+      StaleHandle who (SomeHandle h)
+
+{-# INLINEABLE castEntry #-}
+castEntry
+  :: forall r ctx alloc
+   . (Resource r)
+  => Text
+  -> ResourceEntry ctx alloc
+  -> IO (Desc r, IORef (Maybe r))
+castEntry who entry = case entry.payload of
+  SomeEntry rep desc objRef ->
+    case eqTypeRep rep (typeRep @r) of
+      Just HRefl -> pure (desc, objRef)
+      Nothing ->
+        throwIO $ TypeMismatch who (SomeTypeRep rep) (SomeTypeRep (typeRep @r))
+
+describeEntry :: SomeEntry ctx alloc -> Text
+describeEntry (SomeEntry (_ :: TypeRep r) desc _) = describeDesc @r desc
diff --git a/src/Fragr/Recycle.hs b/src/Fragr/Recycle.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Recycle.hs
@@ -0,0 +1,113 @@
+-- | Deferred, Vulkan-style resource reclamation.
+module Fragr.Recycle
+  ( RecycleQueue (..)
+  , RetireItem
+  , newRecycleQueue
+  , mkRetireItem
+  , retireItem
+  , acquireItem
+  , releaseItem
+  , collect
+  ) where
+
+import Control.Exception (onException)
+import Control.Monad.IO.Class (MonadIO (..))
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Data.IntMap.Strict qualified as IntMap
+import Data.Word (Word64)
+
+import Fragr.Types (QueueId (..))
+
+{- |
+A Vulkan-style recycle queue for deferred resource reclamation. Retired
+items carry the per-queue timeline values that must be reached before they
+may be reclaimed and an in-use refcount; 'collect' destroys (or, for a
+pooling backend, could hand back) exactly those whose timelines are all met
+and whose refcount is zero.
+
+It is a plain single-threaded 'IORef' cell; the library uses one from
+'executeQueued', but it is a standalone utility a backend may drive itself.
+-}
+newtype RecycleQueue = RecycleQueue (IORef [RetireItem])
+
+{- |
+One resource awaiting reclamation: its per-queue timeline requirements, an
+in-use refcount (see 'acquireItem' / 'releaseItem') and the action that
+actually frees it.
+-}
+data RetireItem = RetireItem
+  { retireEntry :: Int
+  , retireReqs :: [(QueueId, Word64)]
+  , retireRefs :: IORef Int
+  , retireDestroy :: IO ()
+  }
+
+-- | A fresh, empty recycle queue.
+{-# INLINEABLE newRecycleQueue #-}
+newRecycleQueue :: (MonadIO m) => m RecycleQueue
+newRecycleQueue = liftIO (RecycleQueue <$> newIORef [])
+
+{- |
+Build a retire item: an identifying tag (the resource's entry id), the
+per-queue timeline values that must be reached before it may be reclaimed,
+and the destroy action to run when it is. Starts with a zero refcount.
+-}
+{-# INLINE mkRetireItem #-}
+mkRetireItem :: (MonadIO m) => Int -> [(QueueId, Word64)] -> IO () -> m RetireItem
+mkRetireItem tag reqs destroy = liftIO do
+  refs <- newIORef 0
+  pure RetireItem{retireEntry = tag, retireReqs = reqs, retireRefs = refs, retireDestroy = destroy}
+
+-- | Hand an item to the recycle queue for eventual reclamation.
+{-# INLINE retireItem #-}
+retireItem :: (MonadIO m) => RecycleQueue -> RetireItem -> m ()
+retireItem (RecycleQueue ref) item = liftIO $ modifyIORef' ref (item :)
+
+-- | Add an in-use reference; 'collect' will not reclaim while any are held.
+{-# INLINE acquireItem #-}
+acquireItem :: (MonadIO m) => RetireItem -> m ()
+acquireItem item = liftIO $ modifyIORef' item.retireRefs (+ 1)
+
+-- | Drop an in-use reference added by 'acquireItem'.
+{-# INLINE releaseItem #-}
+releaseItem :: (MonadIO m) => RetireItem -> m ()
+releaseItem item = liftIO $ modifyIORef' item.retireRefs (subtract 1)
+
+{- |
+Given the currently-reached timeline value per queue, reclaim every retired
+item whose requirements are all met and whose in-use refcount is zero,
+running its destroy action. Items not yet reclaimable stay queued. Returns
+the tags (entry ids) of the items reclaimed.
+-}
+{-# INLINEABLE collect #-}
+collect :: (MonadIO m) => RecycleQueue -> [(QueueId, Word64)] -> m [Int]
+collect (RecycleQueue ref) done = liftIO do
+  items <- readIORef ref
+  verdicts <- traverse decide items
+  let
+    reclaimed = do
+      (item, True) <- verdicts
+      pure item
+    kept = do
+      (item, False) <- verdicts
+      pure item
+  -- Store the survivors /before/ running destroy actions: a pooling backend's
+  -- destroy may itself 'retireItem' a follow-up onto this same queue, and a
+  -- post-destroy 'writeIORef' would clobber it. Should a destroy throw, the
+  -- items not yet destroyed go back onto the queue instead of leaking.
+  writeIORef ref kept
+  destroyAll reclaimed
+  pure (map (.retireEntry) reclaimed)
+  where
+    doneMap = IntMap.fromList do
+      (QueueId q, v) <- done
+      pure (q, v)
+    met item = all (\(QueueId q, v) -> IntMap.findWithDefault 0 q doneMap >= v) item.retireReqs
+    decide item = do
+      refs <- readIORef item.retireRefs
+      pure (item, refs <= 0 && met item)
+    destroyAll = \case
+      [] -> pure ()
+      item : rest -> do
+        item.retireDestroy `onException` modifyIORef' ref (rest <>)
+        destroyAll rest
diff --git a/src/Fragr/Resource.hs b/src/Fragr/Resource.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Resource.hs
@@ -0,0 +1,132 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+
+-- | The contract a user resource type implements to participate in the graph.
+module Fragr.Resource
+  ( Resource (..)
+  , Access (..)
+  , accessId
+  ) where
+
+import Data.Text (Text)
+import Type.Reflection
+
+import Fragr.Types (Handle (..), QueueId, handleId)
+
+{- |
+The contract a user resource type must satisfy to participate in the
+graph. @r@ is the resource object (e.g. a texture wrapper); 'Desc' is its
+plain-data allocation descriptor; 'Alloc' and 'Ctx' are the opaque user
+values forwarded from 'execute' to allocation and hooks respectively;
+'Flags' is the per-access payload its hooks receive.
+
+The hooks ('preRead', 'preWrite', 'preAcquire', 'preRelease'),
+'isShared' and 'describeDesc' are optional; their defaults do nothing,
+report unshared, and return the empty string.
+-}
+class (Typeable r, Ord (Flags r), Show (Flags r)) => Resource r where
+  -- | Allocation descriptor (extent, format, size, ...). Immutable.
+  type Desc r
+
+  -- | Opaque allocator value passed through from 'execute'.
+  type Alloc r
+
+  -- | Opaque context value passed through from 'execute'.
+  type Ctx r
+
+  {- | Per-access flags handed to this resource's hooks: an image layout,
+  a stage/access mask pair, whatever the backend diffs against. Any type
+  with 'Ord' and 'Show' will do; defaults to @()@ for resources whose
+  hooks need no payload.
+  -}
+  type Flags r
+
+  type Flags r = ()
+
+  {- | Materialize the resource. Called by the graph during 'execute',
+  before the first pass that uses the resource.
+  -}
+  createResource :: Desc r -> Alloc r -> IO r
+
+  {- | Release the resource. Called by the graph after the last pass that
+  uses the resource.
+  -}
+  destroyResource :: Desc r -> Alloc r -> r -> IO ()
+
+  {- | Hook invoked before a pass's execution callback, once per declared
+  read with flags ('readWith'). The 'Handle' names the version node the
+  access is behind — the same identity the schedule's 'Access' lists carry
+  ('Fragr.Resource.accessId'), so a backend can key per-node decisions.
+  -}
+  preRead :: Handle r -> Desc r -> Flags r -> Ctx r -> r -> IO ()
+  preRead _ _ _ _ _ = pure ()
+
+  -- | Same as 'preRead', for declared writes ('writeWith').
+  preWrite :: Handle r -> Desc r -> Flags r -> Ctx r -> r -> IO ()
+  preWrite _ _ _ _ _ = pure ()
+
+  {- | Hook invoked by 'executeQueued' on the consuming side of a
+  cross-queue hand-off, before the pass's execution callback, once per
+  'acquires' entry carrying flags. The 'QueueId' is the peer — the queue
+  the resource is coming from — so a backend can name both sides of an
+  ownership transfer.
+  -}
+  preAcquire :: Handle r -> Desc r -> Flags r -> QueueId -> Ctx r -> r -> IO ()
+  preAcquire _ _ _ _ _ _ = pure ()
+
+  {- | Hook invoked by 'executeQueued' on the producing side of a
+  cross-queue hand-off, after the pass's execution callback, once per
+  'releases' entry carrying flags — the consuming access's, so the
+  producer knows the target state to release into. The 'QueueId' is the
+  peer: the queue the resource is going to.
+  -}
+  preRelease :: Handle r -> Desc r -> Flags r -> QueueId -> Ctx r -> r -> IO ()
+  preRelease _ _ _ _ _ _ = pure ()
+
+  {- | Whether the allocation tolerates concurrent access from several
+  queue families at once (Vulkan @CONCURRENT@ sharing). Imports read it
+  off the object to exempt the entry from single-owner validation; a
+  created transient has no object at registration, so mark those with
+  'Fragr.Graph.markShared'.
+  -}
+  isShared :: r -> Bool
+  isShared _ = False
+
+  {- | Human-readable descriptor summary for visualization output.
+  Call with an explicit type application: @describeDesc \@r desc@.
+  -}
+  describeDesc :: Desc r -> Text
+  describeDesc _ = ""
+
+{- | One declared access: a handle with the declaration's flags
+('Nothing' for the flagless 'Fragr.Builder.read' / 'Fragr.Builder.write').
+The handle's resource type is erased but keeps its flags honest.
+-}
+data Access = forall r. (Resource r) => Access
+  { handle :: Handle r
+  , flags :: Maybe (Flags r)
+  }
+
+-- | The node id behind the access's handle.
+accessId :: Access -> Int
+accessId Access{handle} = handleId handle
+
+instance Eq Access where
+  a == b = compare a b == EQ
+
+-- Node id first; equal ids name one entry, hence one resource type, so
+-- the flags tie-break always finds the 'HRefl' for honest handles.
+instance Ord Access where
+  compare (Access (h1 :: Handle r1) f1) (Access (h2 :: Handle r2) f2) =
+    compare (handleId h1) (handleId h2) <> case eqTypeRep (typeRep @r1) (typeRep @r2) of
+      Just HRefl -> compare f1 f2
+      Nothing -> compare (SomeTypeRep (typeRep @r1)) (SomeTypeRep (typeRep @r2))
+
+instance Show Access where
+  showsPrec d (Access h f) =
+    showParen (d > 10) $
+      showString "Access {handle = "
+        . shows h
+        . showString ", flags = "
+        . shows f
+        . showString "}"
diff --git a/src/Fragr/Snapshot.hs b/src/Fragr/Snapshot.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Snapshot.hs
@@ -0,0 +1,192 @@
+-- | Read-only introspection of the graph, for debug output and testing.
+module Fragr.Snapshot
+  ( Snapshot (..)
+  , PassInfo (..)
+  , NodeInfo (..)
+  , EntryInfo (..)
+  , RetireInfo (..)
+  , snapshot
+  ) where
+
+import Control.Monad.IO.Class (MonadIO (..))
+import Data.Foldable (toList)
+import Data.IORef (readIORef)
+import Data.IntMap.Strict qualified as IntMap
+import Data.Maybe (maybeToList)
+import Data.Sequence qualified as Seq
+import Data.Text (Text)
+import Data.Traversable (for)
+import Data.Word (Word64)
+
+import Fragr.Compile (canExecute)
+import Fragr.Graph (FrameGraph (..), PassNode (..), ResourceEntry (..), ResourceNode (..), describeEntry, touchedNodes)
+import Fragr.Resource (Access)
+import Fragr.Sync (Compiled (..), PassSync)
+import Fragr.Types (QueueId, SomeHandle)
+
+-- | Read-only view of the whole graph, for debug output and testing.
+data Snapshot = Snapshot
+  { passes :: [PassInfo]
+  , nodes :: [NodeInfo]
+  , entries :: [EntryInfo]
+  , retires :: [RetireInfo]
+  }
+  deriving stock (Show)
+
+-- | Read-only view of a 'PassNode' (compile-phase state included).
+data PassInfo = PassInfo
+  { passId :: Int
+  , name :: Text
+  , creates :: [SomeHandle]
+  , reads :: [Access]
+  , writes :: [Access]
+  , sideEffect :: Bool
+  , queue :: QueueId
+  , refCount :: Int
+  , canExecute :: Bool
+  , sync :: Maybe PassSync
+  -- ^ the pass's sync schedule after 'Fragr.Compile.compile' ('Nothing' if culled)
+  , level :: Maybe Int
+  {- ^ The pass's dependency level: the longest chain of ordering edges
+  reaching it, counted in edges ('Nothing' if culled). Meaningful only
+  after 'Fragr.Compile.compile'.
+
+  Passes sharing a level have no path between them, so nothing orders them
+  and they may overlap whatever their queues. The converse does not hold: a
+  level gap is a path's /length/, not a path — it never proves two passes
+  cannot overlap. The level count is the critical path, and the widest level
+  is the concurrency the graph permits.
+  -}
+  , antiAfter :: [Int]
+  {- ^ The executing passes that rename a resource this one reads, and so
+  must run after it, though no data flows to them (write-after-read). Empty
+  if culled, and before 'Fragr.Compile.compile'.
+  -}
+  }
+  deriving stock (Show)
+
+{- | Read-only view of a transient entry's deferred-reclamation
+requirements: the per-queue timeline value that must be reached before it
+may be reclaimed. Imports never appear — the graph does not reclaim them.
+-}
+data RetireInfo = RetireInfo
+  { entryId :: Int
+  , requirements :: [(QueueId, Word64)]
+  }
+  deriving stock (Show)
+
+-- | Read-only view of a 'ResourceNode'.
+data NodeInfo = NodeInfo
+  { nodeId :: Int
+  , name :: Text
+  , resourceId :: Int
+  , version :: Int
+  , refCount :: Int
+  }
+  deriving stock (Show)
+
+-- | Read-only view of a 'ResourceEntry'.
+data EntryInfo = EntryInfo
+  { entryId :: Int
+  , version :: Int
+  , imported :: Bool
+  , description :: Text
+  , live :: Maybe (Int, Int)
+  {- ^ The entry's live range over the executing passes, as inclusive
+  positions in execution order ('Nothing' when no executing pass touches
+  it). Meaningful only after 'Fragr.Compile.compile'.
+
+  Disjoint ranges are the raw material for plan-time aliasing — placing
+  two entries in one allocation, decided before recording, with no
+  reclamation bookkeeping at run time. Two conditions ride on the backend,
+  because the range alone does not carry them:
+
+  * Only entries the graph owns may be aliased: an import's memory belongs
+    to the caller, whatever its range says ('imported' tells them apart).
+
+  * Positions order the executing passes globally, but passes on different
+    queues run concurrently, so disjoint positions do not imply disjoint
+    lifetimes across queues. Only ranges whose passes are ordered — one
+    queue, or a pair the schedule already synchronizes — are safe to alias.
+
+  The backend still owes the aliasing barrier at each handover (the second
+  user's contents are undefined until it writes them).
+  -}
+  }
+  deriving stock (Show)
+
+{- |
+Capture the current state of the graph. Callable at any time, but the
+reference counts and culling state are meaningful only after 'compile'.
+-}
+{-# INLINEABLE snapshot #-}
+snapshot :: (MonadIO m) => FrameGraph ctx alloc -> m Snapshot
+snapshot g = liftIO do
+  passes <- readIORef g.passesRef
+  nodes <- readIORef g.nodesRef
+  entries <- readIORef g.entriesRef
+  compiled <- readIORef g.compiledRef
+  let
+    refsMap = maybe IntMap.empty (.passRefs) compiled
+    passRef p = IntMap.findWithDefault 0 p.passId refsMap
+    nodeRef n = maybe 0 (\c -> IntMap.findWithDefault 0 n.nodeId c.nodeRefs) compiled
+    passSyncOf p = compiled >>= \c -> IntMap.lookup p.passId c.passSync
+    passLevelOf p = compiled >>= \c -> IntMap.lookup p.passId c.passLevel
+    passAntiOf p = maybe [] (\c -> IntMap.findWithDefault [] p.passId c.passAnti) compiled
+    retireInfos = maybe [] retiresOf compiled
+    retiresOf c = do
+      (e, reqs) <- IntMap.toList c.entryRetire
+      pure RetireInfo{entryId = e, requirements = reqs}
+    -- Live ranges: for each entry, the first and last executing pass that
+    -- touches any of its versions, as positions in execution order.
+    entryOfNode = IntMap.fromList do
+      n <- toList nodes
+      pure (n.nodeId, n.resourceId)
+    executing = maybe [] (\c -> filter (canExecute c.passRefs) (toList passes)) compiled
+    liveRanges = IntMap.fromListWith (\(a, b) (c, d) -> (min a c, max b d)) do
+      (pos, p) <- zip [0 ..] executing
+      node <- touchedNodes p
+      rid <- maybeToList (IntMap.lookup node entryOfNode)
+      pure (rid, (pos, pos))
+  entryInfos <- for (toList entries) \entry -> do
+    v <- readIORef entry.versionRef
+    pure
+      EntryInfo
+        { entryId = entry.entryId
+        , version = v
+        , imported = entry.imported
+        , description = describeEntry entry.payload
+        , live = IntMap.lookup entry.entryId liveRanges
+        }
+  pure
+    Snapshot
+      { passes = do
+          p <- toList passes
+          pure
+            PassInfo
+              { passId = p.passId
+              , name = p.name
+              , creates = p.creates
+              , reads = p.reads
+              , writes = p.writes
+              , sideEffect = p.sideEffect
+              , queue = p.queue
+              , refCount = passRef p
+              , canExecute = canExecute refsMap p
+              , sync = passSyncOf p
+              , level = passLevelOf p
+              , antiAfter = passAntiOf p
+              }
+      , nodes = do
+          n <- toList nodes
+          pure
+            NodeInfo
+              { nodeId = n.nodeId
+              , name = (entries `Seq.index` n.resourceId).name
+              , resourceId = n.resourceId
+              , version = n.version
+              , refCount = nodeRef n
+              }
+      , entries = entryInfos
+      , retires = retireInfos
+      }
diff --git a/src/Fragr/Snapshot/Dot.hs b/src/Fragr/Snapshot/Dot.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Snapshot/Dot.hs
@@ -0,0 +1,492 @@
+{-|
+Graphviz DOT exporters for compiled frame graphs.
+
+@
+FG.compile graph
+dotSource <- Dot.dump graph
+@
+
+Two views: 'dump' renders the dataflow — passes and resource versions —
+and 'dumpSync' the compiled schedule as queue lanes ('renderSync').
+
+Imports sharing a dotted name prefix (e.g. mip levels @img.mip0@,
+@img.mip1@) render as one record node with a cell per member, keeping the
+family together in an array instead of letting the layout scatter it. The
+grouping covers the version-1 identities; versions minted by writes render
+in the dataflow as usual.
+
+Any other visualization can be written the same way, against the
+read-only 'Snapshot' from 'FG.snapshot'.
+-}
+module Fragr.Snapshot.Dot
+  ( dump
+  , dumpWith
+  , render
+  , Options (..)
+  , defaultOptions
+
+    -- * The schedule view
+  , dumpSync
+  , renderSync
+  ) where
+
+import Control.Monad (guard)
+import Data.IntMap.Strict (IntMap)
+import Data.IntMap.Strict qualified as IntMap
+import Data.List (nub)
+import Data.Map.Strict qualified as Map
+import Data.Maybe (maybeToList)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Word (Word64)
+
+import Fragr (Access (..), EntryInfo (..), EventId (..), FrameGraph, NodeInfo (..), PassInfo (..), PassSync (..), QueueId (..), RetireInfo (..), Snapshot (..), SyncEvent (..), Transfer (..), Wait (..), accessId, handleId, snapshot, someHandleId, transferId)
+
+dump :: FrameGraph ctx alloc -> IO Text
+dump = dumpWith defaultOptions
+
+dumpWith :: Options -> FrameGraph ctx alloc -> IO Text
+dumpWith opts g = render opts <$> snapshot g
+
+dumpSync :: FrameGraph ctx alloc -> IO Text
+dumpSync g = renderSync <$> snapshot g
+
+data Options = Options
+  { clusterImports :: Bool
+  {- ^ Fence the initial import versions in a dotted @Imported@ frame,
+  instead of letting them float near their consumers.
+  -}
+  , stratify :: Bool
+  {- ^ Pin each dependency level ('Fragr.Snapshot.PassInfo') to one rank, so
+  rows are levels and the row count is the critical path. Culled passes stay
+  unpinned.
+  -}
+  , antiEdges :: Bool
+  {- ^ Overlay the write-after-read edges ('Fragr.Snapshot.PassInfo') as
+  dashed reader -> renamer edges. Drawn without layout constraint, so they
+  annotate the dataflow rather than reshape it.
+  -}
+  }
+  deriving stock (Eq, Show)
+
+defaultOptions :: Options
+defaultOptions =
+  Options
+    { clusterImports = False
+    , stratify = False
+    , antiEdges = True
+    }
+
+render :: Options -> Snapshot -> Text
+render opts s =
+  Text.unlines $
+    concat
+      [ ["digraph FrameGraph {"]
+      , map indent header
+      , map (indent . passVertex) s.passes
+      , map (indent . nodeVertex) (filter standalone s.nodes)
+      , map (indent . familyVertex) importFamilies
+      , concatMap (map indent . writeEdges) s.passes
+      , concatMap (map indent . readEdges) s.nodes
+      , map indent antiEdgeLines
+      , concatMap (map indent . createCluster) s.passes
+      , map indent importedCluster
+      , map indent levelRanks
+      , ["}"]
+      ]
+  where
+    header =
+      [ "graph [rankdir=TB, splines=spline" <> (if opts.stratify then ", newrank=true" else "") <> "]"
+      , "node [shape=record, style=\"rounded,filled\", fontname=\"helvetica\", fontsize=10]"
+      ]
+
+    entryById :: IntMap EntryInfo
+    entryById = IntMap.fromList do
+      e <- s.entries
+      pure (e.entryId, e)
+
+    nodeById :: IntMap NodeInfo
+    nodeById = IntMap.fromList do
+      n <- s.nodes
+      pure (n.nodeId, n)
+
+    passKey :: PassInfo -> Text
+    passKey p = passIdKey p.passId
+
+    passIdKey :: Int -> Text
+    passIdKey i = "P" <> tshow i
+
+    nodeKey :: NodeInfo -> Text
+    nodeKey n = "R" <> tshow n.resourceId <> "_" <> tshow n.version
+
+    handleKey :: Int -> Text
+    handleKey i = nodeRef (nodeById IntMap.! i)
+
+    -- Where edges attach: a family member's port, or the node's own vertex.
+    nodeRef :: NodeInfo -> Text
+    nodeRef n = IntMap.findWithDefault (nodeKey n) n.nodeId familyPorts
+
+    standalone :: NodeInfo -> Bool
+    standalone n = not (IntMap.member n.nodeId familyPorts)
+
+    -- Imported v1 identities, the Imported cluster's population.
+    importedV1 :: [NodeInfo]
+    importedV1 = do
+      n <- s.nodes
+      guard (n.version == 1)
+      guard (entryById IntMap.! n.resourceId).imported
+      pure n
+
+    -- Imports sharing a name prefix before the last dot, two or more
+    -- strong; singleton prefixes and undotted names stay standalone.
+    -- Transients never group: they live inside per-pass create clusters,
+    -- and one record node cannot span two clusters.
+    importFamilies :: [Family]
+    importFamilies = do
+      (prefix, members@(m : _ : _)) <- Map.toAscList byPrefix
+      pure Family{key = "G" <> tshow m.node.nodeId, prefix, members}
+      where
+        byPrefix = Map.fromListWith (flip (<>)) do
+          n <- importedV1
+          (prefix, suffix) <- maybeToList (familyOf n.name)
+          pure (prefix, [Member{node = n, suffix}])
+
+    familyOf :: Text -> Maybe (Text, Text)
+    familyOf name = do
+      let (pre, suffix) = Text.breakOnEnd "." name
+      guard (not (Text.null pre))
+      pure (Text.dropEnd 1 pre, suffix)
+
+    familyPorts :: IntMap Text
+    familyPorts = IntMap.fromList do
+      f <- importFamilies
+      m <- f.members
+      pure (m.node.nodeId, f.key <> ":" <> portName m)
+
+    familyVertex f =
+      f.key
+        <> " [label=\"{"
+        <> esc f.prefix
+        <> "|{"
+        <> Text.intercalate "|" (map cell f.members)
+        <> "}}\", fillcolor=lightsteelblue]"
+      where
+        cell m = "<" <> portName m <> "> " <> esc m.suffix
+
+    passVertex p =
+      passKey p
+        <> " [label=\"{"
+        <> esc p.name
+        <> (if p.sideEffect then " *" else "")
+        <> "|refs: "
+        <> tshow p.refCount
+        <> ", id: "
+        <> tshow p.passId
+        <> "}\", fillcolor="
+        <> (if p.canExecute then "orange" else "lightgray")
+        <> "]"
+
+    nodeVertex n =
+      nodeKey n
+        <> " [label=\"{"
+        <> esc n.name
+        <> (if n.version > 1 then " v" <> tshow n.version else "")
+        <> (if Text.null desc then "" else "|" <> esc desc)
+        <> "|id: "
+        <> tshow n.resourceId
+        <> ", refs: "
+        <> tshow n.refCount
+        <> "}\", fillcolor="
+        <> fill
+        <> "]"
+      where
+        entry = entryById IntMap.! n.resourceId
+        desc = entry.description
+        fill
+          | not entry.imported = "skyblue"
+          | n.version == 1 = "lightsteelblue"
+          | otherwise = "steelblue"
+
+    writeEdges p = fan (passKey p) (map (handleKey . accessId) p.writes) "orangered"
+
+    -- Node id -> keys of the passes reading it, in registration order, one
+    -- per pass.
+    readersOfNode :: IntMap [Text]
+    readersOfNode = IntMap.fromListWith (flip (<>)) do
+      p <- s.passes
+      i <- nub (map accessId p.reads)
+      pure (i, [passKey p])
+
+    readEdges n = fan (nodeRef n) (IntMap.findWithDefault [] n.nodeId readersOfNode) "yellowgreen"
+
+    antiEdgeLines
+      | not opts.antiEdges = []
+      | otherwise = do
+          r <- s.passes
+          w <- r.antiAfter
+          pure (passKey r <> " -> " <> passIdKey w <> " [style=dashed, color=slategray, constraint=false]")
+
+    {- One rank per row of the layering, imports first: a level's passes sit
+    on rank @2*level+1@ and the nodes they produce on the row below, so
+    every edge points strictly downward and dot never has to route a flat
+    one. Pinning the nodes as well as the passes is what keeps that true —
+    left free, an import lands on a pass's rank and its edge is dropped.
+    Culled passes and their nodes stay unpinned; @newrank@ lets a rankset
+    cross the create clusters instead of emptying them.
+    -}
+    levelRanks
+      | not opts.stratify = []
+      | otherwise = do
+          (_rank, keys) <- IntMap.toAscList byRank
+          pure ("{ rank=same; " <> Text.unwords keys <> " }")
+      where
+        byRank = IntMap.fromListWith (flip (<>)) (imports <> passes <> produced)
+        imports = map (\k -> (0, [k])) importedKeys
+        passes = do
+          p <- s.passes
+          l <- maybeToList p.level
+          pure (2 * l + 1, [passKey p])
+        produced = do
+          n <- filter standalone s.nodes
+          l <- maybeToList (IntMap.lookup n.nodeId levelOfNode)
+          pure (2 * l + 2, [nodeKey n])
+
+        -- A node sits on the row below the pass that produces it: the one
+        -- that writes it, or, for a transient nobody writes, the one that
+        -- created it. Imports and culled passes' nodes stay unpinned.
+        levelOfNode :: IntMap Int
+        levelOfNode = IntMap.fromList do
+          p <- s.passes
+          l <- maybeToList p.level
+          i <- map accessId p.writes <> map someHandleId p.creates
+          pure (i, l)
+
+    fan from targets color
+      | null targets = []
+      | otherwise =
+          [from <> " -> { " <> Text.unwords targets <> " } [color=" <> color <> "]"]
+
+    createCluster p
+      | null p.creates = []
+      | otherwise =
+          [ "subgraph cluster_" <> passKey p <> " {"
+          , indent "style=rounded"
+          , indent (Text.unwords (passKey p : map (handleKey . someHandleId) p.creates))
+          , "}"
+          ]
+
+    importedCluster
+      | not opts.clusterImports = []
+      | null importedKeys = []
+      | otherwise =
+          [ "subgraph cluster_imported {"
+          , indent "style=dotted"
+          , indent "label=\"Imported\""
+          , indent (Text.unwords importedKeys)
+          , "}"
+          ]
+
+    -- The vertices carrying the initial import versions: a family's record
+    -- node stands for its members, which have no vertex of their own.
+    importedKeys :: [Text]
+    importedKeys = standaloneKeys <> map (.key) importFamilies
+      where
+        standaloneKeys = do
+          n <- importedV1
+          guard (standalone n)
+          pure (nodeKey n)
+
+{- |
+Render the compiled schedule as queue lanes: one cluster per queue with
+its executing passes in submission order, overlaid with the cross-queue
+timeline waits, split-barrier event pairs, ownership transfers and retire
+points. Where 'render' answers what data flows, this answers what the
+'Fragr.Execute.QueueBackend' will be asked to do.
+-}
+renderSync :: Snapshot -> Text
+renderSync s =
+  Text.unlines $
+    concat
+      [ ["digraph FrameGraphSync {"]
+      , map indent header
+      , concatMap (map indent) lanes
+      , map indent waitEdges
+      , map indent eventEdges
+      , map indent transferEdges
+      , map indent retireLines
+      , ["}"]
+      ]
+  where
+    header =
+      [ "graph [rankdir=LR, splines=spline]"
+      , "node [shape=record, style=\"rounded,filled\", fontname=\"helvetica\", fontsize=10]"
+      ]
+
+    scheduled :: [(PassInfo, PassSync)]
+    scheduled = do
+      p <- s.passes
+      ps <- maybeToList p.sync
+      pure (p, ps)
+
+    nodeById :: IntMap NodeInfo
+    nodeById = IntMap.fromList do
+      n <- s.nodes
+      pure (n.nodeId, n)
+
+    passKey :: PassInfo -> Text
+    passKey p = "P" <> tshow p.passId
+
+    -- One cluster per queue, its passes chained in submission order.
+    lanes = do
+      (q, lane) <- Map.toAscList byQueue
+      pure $
+        concat
+          [
+            [ "subgraph cluster_q" <> tshow q <> " {"
+            , indent "style=rounded"
+            , indent ("label=\"queue " <> tshow q <> "\"")
+            ]
+          , map (indent . laneVertex) lane
+          , map indent (chain (map (passKey . fst) lane))
+          , ["}"]
+          ]
+    byQueue = Map.fromListWith (flip (<>)) do
+      pps@(_pass, ps) <- scheduled
+      let QueueId q = ps.queue
+      pure (q, [pps])
+    laneVertex (p, ps) =
+      passKey p <> " [label=\"{" <> esc p.name <> "|signal " <> tshow ps.signal <> "}\", fillcolor=orange]"
+    chain keys = do
+      (a, b) <- zip keys (drop 1 keys)
+      pure (a <> " -> " <> b <> " [color=gray, weight=100]")
+
+    -- (queue, signaled value) -> the signaling pass.
+    signaler :: Map.Map (Int, Word64) Text
+    signaler = Map.fromList do
+      (p, ps) <- scheduled
+      let QueueId q = ps.queue
+      pure ((q, ps.signal), passKey p)
+
+    waitEdges = do
+      (p, ps) <- scheduled
+      w <- ps.waits
+      let QueueId wq = w.queue
+      src <- maybeToList (Map.lookup (wq, w.value) signaler)
+      let covers = Text.intercalate ", " (map accessText w.covers)
+      pure $
+        src
+          <> " -> "
+          <> passKey p
+          <> " [label=\">="
+          <> tshow w.value
+          <> (if Text.null covers then "" else "\\n" <> escPlain covers)
+          <> "\", color=royalblue]"
+
+    eventEnd pick = IntMap.fromList do
+      (p, ps) <- scheduled
+      se <- pick ps
+      let EventId e = se.event
+      pure (e, passKey p)
+    eventEdges = do
+      (e, src) <- IntMap.toList (eventEnd (.signalEvents))
+      dst <- maybeToList (IntMap.lookup e (eventEnd (.waitEvents)))
+      pure (src <> " -> " <> dst <> " [label=\"e" <> tshow e <> "\", style=dashed, color=slategray]")
+
+    -- A release edge finds its acquirers by node and source queue; a
+    -- fan-out read acquires on every consumer.
+    acquirers :: Map.Map (Int, Int) [(Int, Text)]
+    acquirers = Map.fromListWith (flip (<>)) do
+      (p, ps) <- scheduled
+      t@(Transfer _h (QueueId src) _flags) <- ps.acquires
+      let QueueId consumer = ps.queue
+      pure ((transferId t, src), [(consumer, passKey p)])
+    transferEdges = do
+      (p, ps) <- scheduled
+      t@(Transfer h (QueueId peer) flags) <- ps.releases
+      let QueueId srcQ = ps.queue
+      (consumer, dst) <- Map.findWithDefault [] (transferId t, srcQ) acquirers
+      guard (consumer == peer)
+      pure $
+        passKey p
+          <> " -> "
+          <> dst
+          <> " [label=\""
+          <> escPlain (accessText (Access h flags))
+          <> "\", color=purple]"
+
+    -- Retire points: a note on the last executing pass touching the
+    -- entry, carrying the per-queue timeline requirements.
+    entryOfNode = IntMap.fromList do
+      n <- s.nodes
+      pure (n.nodeId, n.resourceId)
+    entryName = IntMap.fromList do
+      n <- s.nodes
+      pure (n.resourceId, n.name)
+    -- 'IntMap.fromList' keeps the last occurrence, and 'scheduled' walks
+    -- in execution order: the last executing toucher per entry.
+    lastToucher :: IntMap Text
+    lastToucher = IntMap.fromList do
+      (p, _ps) <- scheduled
+      i <- map someHandleId p.creates <> map accessId (p.reads <> p.writes)
+      e <- maybeToList (IntMap.lookup i entryOfNode)
+      pure (e, passKey p)
+    retireLines = concat do
+      r <- s.retires
+      src <- maybeToList (IntMap.lookup r.entryId lastToucher)
+      let
+        key = "T" <> tshow r.entryId
+        reqs = Text.intercalate ", " do
+          (QueueId q, v) <- r.requirements
+          pure ("q" <> tshow q <> ">=" <> tshow v)
+        rname = IntMap.findWithDefault (tshow r.entryId) r.entryId entryName
+      pure
+        [ key <> " [shape=note, label=\"retire " <> escPlain rname <> "\\n" <> escPlain reqs <> "\", fillcolor=khaki]"
+        , src <> " -> " <> key <> " [style=dotted, color=gray50, arrowhead=none]"
+        ]
+
+    accessText :: Access -> Text
+    accessText (Access h flags) = name <> maybe "" (\f -> " @" <> tshow f) flags
+      where
+        name = maybe (tshow (handleId h)) nodeText (IntMap.lookup (handleId h) nodeById)
+        nodeText n = n.name <> (if n.version > 1 then " v" <> tshow n.version else "")
+
+-- | One rendered import family: its vertex key, shared prefix and members.
+data Family = Family
+  { key :: Text
+  , prefix :: Text
+  , members :: [Member]
+  }
+
+-- | One family member: the imported v1 node and its name's suffix.
+data Member = Member
+  { node :: NodeInfo
+  , suffix :: Text
+  }
+
+-- | The record port a member's edges attach to.
+portName :: Member -> Text
+portName m = "n" <> tshow m.node.nodeId
+
+indent :: Text -> Text
+indent = ("  " <>)
+
+esc :: Text -> Text
+esc = Text.concatMap \case
+  '\\' -> "\\\\"
+  '"' -> "\\\""
+  '{' -> "\\{"
+  '}' -> "\\}"
+  '|' -> "\\|"
+  '<' -> "\\<"
+  '>' -> "\\>"
+  c -> Text.singleton c
+
+-- | Escape for plain (non-record) labels; record cells use 'esc'.
+escPlain :: Text -> Text
+escPlain = Text.concatMap \case
+  '\\' -> "\\\\"
+  '"' -> "\\\""
+  c -> Text.singleton c
+
+tshow :: (Show a) => a -> Text
+tshow = Text.pack . show
diff --git a/src/Fragr/Snapshot/JSON.hs b/src/Fragr/Snapshot/JSON.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Snapshot/JSON.hs
@@ -0,0 +1,226 @@
+{-|
+JSON exporter for the interactive viewer: https://skaarj1989.github.io/FrameGraph/
+
+@
+FG.compile graph
+jsonSource <- Json.dump graph
+@
+
+'fromSnapshot' projects a compiled graph's 'Snapshot' onto the data model a
+browser-based viewer consumes, and 'render' serializes it — hand-rolled, so
+the library stays free of an aeson dependency. The types keep 'Generic'
+instances for consumers who prefer deriving their own.
+
+Unlike the Graphviz writer ("Fragr.Snapshot.Dot"), which draws one vertex
+per resource /version/, this form collapses a resource's whole rename chain
+(§3.4) into a single record keyed by resource /entry/ id, aggregating the
+readers and writers of every version. 'fromSnapshot' accumulates in
+version-major, then registration order, holding one id per declared access
+with no deduplication.
+-}
+module Fragr.Snapshot.JSON
+  ( dump
+  , render
+  , fromSnapshot
+  , ViewerGraph (..)
+  , ViewerPass (..)
+  , ViewerResource (..)
+  ) where
+
+import Control.Monad (guard)
+import Data.IntMap.Strict (IntMap)
+import Data.IntMap.Strict qualified as IntMap
+import Data.List (intersperse)
+import Data.Maybe (listToMaybe, maybeToList)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.Lazy qualified as Text.Lazy
+import Data.Text.Lazy.Builder (Builder)
+import Data.Text.Lazy.Builder qualified as Builder
+import Data.Text.Lazy.Builder.Int qualified as Builder
+import GHC.Generics (Generic)
+
+import Fragr (EntryInfo (..), FrameGraph, NodeInfo (..), PassInfo (..), Snapshot (..), accessId, snapshot, someHandleId)
+
+dump :: FrameGraph ctx alloc -> IO Text
+dump g = render . fromSnapshot <$> snapshot g
+
+{- | Serialize onto the viewer document (SPEC §8.3), compact. A resource's
+empty @readers@/@writers@ and absent @createdBy@ are omitted, as the schema
+allows; a pass keeps its @reads@/@writes@ even when empty.
+-}
+render :: ViewerGraph -> Text
+render vg =
+  Text.Lazy.toStrict . Builder.toLazyText $
+    object
+      [ ("passes", array (map passJson vg.passes))
+      , ("resources", array (map resourceJson vg.resources))
+      ]
+  where
+    passJson p =
+      object
+        [ ("id", int p.id)
+        , ("name", string p.name)
+        , ("culled", bool p.culled)
+        , ("reads", ints p.reads)
+        , ("writes", ints p.writes)
+        ]
+    resourceJson r =
+      object $
+        concat
+          [
+            [ ("id", int r.id)
+            , ("name", string r.name)
+            , ("description", string r.description)
+            , ("transient", bool r.transient)
+            ]
+          , do
+              c <- maybeToList r.createdBy
+              pure ("createdBy", int c)
+          , do
+              guard (not (null r.readers))
+              pure ("readers", ints r.readers)
+          , do
+              guard (not (null r.writers))
+              pure ("writers", ints r.writers)
+          ]
+
+-- | The whole compiled graph: passes and resources as arrays indexed by id.
+data ViewerGraph = ViewerGraph
+  { passes :: [ViewerPass]
+  , resources :: [ViewerResource]
+  }
+  deriving stock (Eq, Show, Generic)
+
+{- | One pass. @reads@ / @writes@ are resource /entry/ ids (one per declared
+access, in access order — not handles or version node ids), and @culled@ is
+@not canExecute@.
+-}
+data ViewerPass = ViewerPass
+  { id :: Int
+  , name :: Text
+  , culled :: Bool
+  , reads :: [Int]
+  , writes :: [Int]
+  }
+  deriving stock (Eq, Show, Generic)
+
+{- | One resource /entry/, with its whole rename chain collapsed into a
+single record. @readers@ / @writers@ are pass ids aggregated across every
+version; @createdBy@ is the creating pass id, present only for transient
+resources (imported resources have no creating pass).
+-}
+data ViewerResource = ViewerResource
+  { id :: Int
+  , name :: Text
+  , description :: Text
+  , transient :: Bool
+  , createdBy :: Maybe Int
+  , readers :: [Int]
+  , writers :: [Int]
+  }
+  deriving stock (Eq, Show, Generic)
+
+-- | Project a graph 'Snapshot' onto the viewer data model.
+fromSnapshot :: Snapshot -> ViewerGraph
+fromSnapshot s =
+  ViewerGraph
+    { passes = map mkPass s.passes
+    , resources = map mkResource s.entries
+    }
+  where
+    -- Resource-node id -> underlying entry id.
+    entryOfNode :: IntMap Int
+    entryOfNode = IntMap.fromList do
+      n <- s.nodes
+      pure (n.nodeId, n.resourceId)
+
+    entryOf :: Int -> Int
+    entryOf i = IntMap.findWithDefault (-1) i entryOfNode
+
+    -- Entry id -> its version nodes, in node (version) order.
+    nodesByEntry :: IntMap [NodeInfo]
+    nodesByEntry = IntMap.fromListWith (flip (<>)) do
+      n <- s.nodes
+      pure (n.resourceId, [n])
+
+    mkPass p =
+      ViewerPass
+        { id = p.passId
+        , name = p.name
+        , culled = not p.canExecute
+        , reads = map (entryOf . accessId) p.reads
+        , writes = map (entryOf . accessId) p.writes
+        }
+
+    -- Node id -> accessing pass ids, one per declared access, in
+    -- registration order; concatenated per entry in node (version) order —
+    -- the accumulation order noted above.
+    readersOfNode = accessesOfNode do
+      p <- s.passes
+      i <- map accessId p.reads
+      pure (i, p.passId)
+    writersOfNode = accessesOfNode do
+      p <- s.passes
+      i <- map accessId p.writes
+      pure (i, p.passId)
+    creatorOfNode = IntMap.fromList do
+      p <- s.passes
+      i <- map someHandleId p.creates
+      pure (i, p.passId)
+
+    accessesOfNode :: [(Int, Int)] -> IntMap [Int]
+    accessesOfNode accesses = IntMap.fromListWith (flip (<>)) do
+      (i, pid) <- accesses
+      pure (i, [pid])
+
+    mkResource e =
+      ViewerResource
+        { id = e.entryId
+        , name = maybe "" (.name) (listToMaybe nodes)
+        , description = e.description
+        , transient = not e.imported
+        , createdBy = listToMaybe do
+            n <- nodes
+            maybeToList (IntMap.lookup n.nodeId creatorOfNode)
+        , readers = accessesOf readersOfNode
+        , writers = accessesOf writersOfNode
+        }
+      where
+        nodes = IntMap.findWithDefault [] e.entryId nodesByEntry
+        accessesOf m = concatMap (\n -> IntMap.findWithDefault [] n.nodeId m) nodes
+
+-- tiny json encoder to avoid aeson deps
+
+object :: [(Text, Builder)] -> Builder
+object kvs = "{" <> mconcat (intersperse "," (map kv kvs)) <> "}"
+  where
+    kv (k, v) = string k <> ":" <> v
+
+array :: [Builder] -> Builder
+array vs = "[" <> mconcat (intersperse "," vs) <> "]"
+
+ints :: [Int] -> Builder
+ints = array . map int
+
+int :: Int -> Builder
+int = Builder.decimal
+
+bool :: Bool -> Builder
+bool b = if b then "true" else "false"
+
+string :: Text -> Builder
+string t = "\"" <> escape t <> "\""
+
+escape :: Text -> Builder
+escape t = case Text.break needsEscape t of
+  (safe, rest) -> case Text.uncons rest of
+    Nothing -> Builder.fromText safe
+    Just (c, more) -> Builder.fromText safe <> escaped c <> escape more
+  where
+    needsEscape c = c == '"' || c == '\\' || c < ' '
+    escaped = \case
+      '"' -> "\\\""
+      '\\' -> "\\\\"
+      c -> unicode (fromEnum c) -- a control char, two hex digits suffice
+    unicode n = "\\u00" <> (if n < 0x10 then "0" else "") <> Builder.hexadecimal n
diff --git a/src/Fragr/Sync.hs b/src/Fragr/Sync.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Sync.hs
@@ -0,0 +1,146 @@
+{-# LANGUAGE TypeFamilies #-}
+
+{-|
+The synchronization-schedule vocabulary.
+
+The per-pass 'PassSync' handed to a 'Fragr.Execute.QueueBackend', its
+'Wait' / 'SyncEvent' / 'Transfer' pieces, and the whole 'Compiled' result
+stored on the graph.
+-}
+module Fragr.Sync
+  ( PassSync (..)
+  , Wait (..)
+  , SyncEvent (..)
+  , Transfer (..)
+  , transferId
+  , Compiled (..)
+  ) where
+
+import Data.IntMap.Strict (IntMap)
+import Data.Text (Text)
+import Data.Word (Word64)
+import Type.Reflection
+
+import Fragr.Resource (Access, Resource (..))
+import Fragr.Types (EventId, Handle, QueueId, handleId)
+
+{- |
+The synchronization schedule computed for one surviving pass. It is a
+purely descriptive object: the library hands it to a 'Fragr.Execute.QueueBackend', which
+maps it onto real primitives (timeline semaphores, events, queue-family
+ownership transfers). See "Fragr" for the module-level notes on how it is
+derived.
+-}
+data PassSync = PassSync
+  { passId :: Int
+  , name :: Text
+  , queue :: QueueId
+  -- ^ the queue this pass is submitted to
+  , waits :: [Wait]
+  {- ^ for each foreign queue this pass depends on — producers of its reads,
+  readers of versions it renames, the sibling pass acquiring ownership for
+  its family — the timeline value to wait for before this pass may run
+  (deduplicated by per-queue watermark)
+  -}
+  , signal :: Word64
+  -- ^ the value this pass signals on its own queue's timeline once done
+  , waitEvents :: [SyncEvent]
+  -- ^ split-barrier events this pass waits on before running
+  , signalEvents :: [SyncEvent]
+  -- ^ split-barrier events this pass signals after running
+  , acquires :: [Transfer]
+  -- ^ resources whose ownership this pass acquires from another queue
+  , releases :: [Transfer]
+  -- ^ outputs consumed on another queue, handed off to it
+  }
+  deriving stock (Eq, Show)
+
+{- |
+One cross-queue timeline wait, with the accesses it protects: the waiting
+pass's own reads behind each data edge from that queue, and its renaming
+writes behind each anti-edge. A backend derives its wait scope (e.g. a
+Vulkan @waitDstStageMask@) from the covered flags. An access declared
+without flags covers 'Nothing' and carries no scope information: the
+backend over-synchronizes (full scope) for it — here and in every other
+@covers@ / @flags@ of the schedule.
+-}
+data Wait = Wait
+  { queue :: QueueId
+  , value :: Word64
+  , covers :: [Access]
+  }
+  deriving stock (Eq, Show)
+
+{- |
+One split-barrier event endpoint, with the accesses it orders on its own
+pass: the producing accesses on a 'signalEvents' entry (source scope), the
+consuming accesses on a 'waitEvents' entry (destination scope).
+-}
+data SyncEvent = SyncEvent
+  { event :: EventId
+  , covers :: [Access]
+  }
+  deriving stock (Eq, Ord, Show)
+
+{- |
+One cross-queue ownership transfer. @peer@ is the other side — the
+destination queue on a 'releases' entry, the source queue on an 'acquires'
+entry. @flags@ are the /consuming/ access's, so both sides know the target
+state to transition into; a handle consumed under several distinct flags
+appears once per flags value, so a backend emitting one release / acquire
+barrier per resource merges those entries itself.
+-}
+data Transfer = forall r. (Resource r) => Transfer
+  { handle :: Handle r
+  , peer :: QueueId
+  , flags :: Maybe (Flags r)
+  }
+
+-- | The node id behind the transfer's handle, to key per-resource state by.
+transferId :: Transfer -> Int
+transferId Transfer{handle} = handleId handle
+
+instance Eq Transfer where
+  a == b = compare a b == EQ
+
+-- Node id first, like 'Access': equal ids imply one resource type.
+instance Ord Transfer where
+  compare (Transfer (h1 :: Handle r1) p1 f1) (Transfer (h2 :: Handle r2) p2 f2) =
+    compare (handleId h1) (handleId h2) <> compare p1 p2 <> case eqTypeRep (typeRep @r1) (typeRep @r2) of
+      Just HRefl -> compare f1 f2
+      Nothing -> compare (SomeTypeRep (typeRep @r1)) (SomeTypeRep (typeRep @r2))
+
+instance Show Transfer where
+  showsPrec d (Transfer h p f) =
+    showParen (d > 10) $
+      showString "Transfer {handle = "
+        . shows h
+        . showString ", peer = "
+        . shows p
+        . showString ", flags = "
+        . shows f
+        . showString "}"
+
+data Compiled = Compiled
+  { passRefs :: IntMap Int
+  -- ^ pass id -> number of its writes still referenced
+  , nodeRefs :: IntMap Int
+  -- ^ node id -> number of surviving declared readers
+  , passSync :: IntMap PassSync
+  -- ^ pass id -> its sync schedule (only for passes that execute)
+  , passLevel :: IntMap Int
+  -- ^ pass id -> its dependency level (only for passes that execute)
+  , passAnti :: IntMap [Int]
+  {- ^ pass id -> the executing passes that rename a resource it reads, and
+  so must run after it (write-after-read)
+  -}
+  , entryRetire :: IntMap [(QueueId, Word64)]
+  {- ^ transient entry id -> per-queue timeline value that must be reached
+  before the resource may be reclaimed
+  -}
+  , retireAfter :: IntMap [Int]
+  {- ^ pass id -> transient entries whose last executing user it is,
+  reclaimed right after the pass runs (side-effecting zero-ref passes count
+  as users too)
+  -}
+  }
diff --git a/src/Fragr/Types.hs b/src/Fragr/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Fragr/Types.hs
@@ -0,0 +1,85 @@
+{-|
+The plain vocabulary types shared across the library.
+
+Versioned resource handles and queue / timeline / event identities.
+-}
+module Fragr.Types
+  ( Handle (..)
+  , handleId
+  , SomeHandle (..)
+  , someHandleId
+  , QueueId (..)
+  , defaultQueue
+  , FamilyId (..)
+  , EventId (..)
+  ) where
+
+{- |
+A handle to one /version/ of a virtual resource of type @r@. Handles are
+cheap value types; a handle is valid ('Fragr.Graph.isValid') iff it names the latest
+version of its resource. A handle superseded by a later 'Fragr.Builder.write' is
+/stale/ and must not be used again.
+
+The resource type rides along from 'Fragr.Builder.create' / 'Fragr.Graph.importResource', so
+declarations take the right 'Fragr.Resource.Flags' and 'Fragr.Exec.get' needs no type
+application — passing another resource's flags or fetching at the wrong
+type is a type error, not a runtime one.
+-}
+newtype Handle r = Handle Int
+  deriving stock (Eq, Ord, Show)
+  deriving newtype (Enum)
+
+-- | The node id behind the handle, shared by every resource type.
+handleId :: Handle r -> Int
+handleId (Handle i) = i
+
+{- | A handle with its resource type erased, for the introspection lists
+that mix resource types ('Fragr.Snapshot.PassInfo', errors). Compares by node id:
+ids are graph-global, so no two resources share one.
+-}
+data SomeHandle = forall r. SomeHandle (Handle r)
+
+someHandleId :: SomeHandle -> Int
+someHandleId (SomeHandle h) = handleId h
+
+instance Eq SomeHandle where
+  a == b = someHandleId a == someHandleId b
+
+instance Ord SomeHandle where
+  compare a b = compare (someHandleId a) (someHandleId b)
+
+instance Show SomeHandle where
+  showsPrec d (SomeHandle h) = showsPrec d h
+
+{- |
+Identifies a submission queue / timeline. The library never interprets it;
+a backend maps it to (say) a Vulkan queue family and its timeline
+semaphore. Passes default to 'defaultQueue' unless 'Fragr.Builder.setQueue' is called.
+-}
+newtype QueueId = QueueId Int
+  deriving stock (Eq, Ord, Show)
+  deriving newtype (Enum)
+
+-- | The queue a pass runs on unless 'Fragr.Builder.setQueue' says otherwise.
+defaultQueue :: QueueId
+defaultQueue = QueueId 0
+
+{- |
+An ownership domain grouping queues ('Fragr.Compile.compileWith'): cross-queue
+transfers are derived once per consuming family, and exclusively-owned
+resources must not leave to two of them. Opaque to the library; a Vulkan
+backend maps it to a queue family index.
+-}
+newtype FamilyId = FamilyId Int
+  deriving stock (Eq, Ord, Show)
+  deriving newtype (Enum)
+
+{- |
+Identifies a split-barrier event used to order two same-queue passes that
+are /not/ adjacent (the producer signals the event after its pass, the
+consumer waits on it before its pass). The library only mints and pairs
+ids; the backend maps them to (say) @VkEvent@s.
+-}
+newtype EventId = EventId Int
+  deriving stock (Eq, Ord, Show)
+  deriving newtype (Enum)
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,25 @@
+module Main (main) where
+
+import Test.Tasty (defaultMain, testGroup)
+
+import Spec.Alias qualified as Alias
+import Spec.Behavior qualified as Behavior
+import Spec.Dot qualified as Dot
+import Spec.Error qualified as Error
+import Spec.JSON qualified as JSON
+import Spec.MultiQueue qualified as MultiQueue
+import Spec.Setup qualified as Setup
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "fragr"
+      [ Behavior.tests
+      , Setup.tests
+      , Error.tests
+      , Dot.tests
+      , JSON.tests
+      , MultiQueue.tests
+      , Alias.tests
+      ]
diff --git a/test/Spec/Alias.hs b/test/Spec/Alias.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/Alias.hs
@@ -0,0 +1,60 @@
+-- | Which entries a backend may legally place in shared memory.
+module Spec.Alias (tests) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase)
+
+import Fragr qualified as FG
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "alias validation"
+    [ acceptsWriteFirst
+    , rejectsReadFirst
+    , renamingToucherReads
+    , fatalBeforeCompileAndUnknown
+    ]
+
+acceptsWriteFirst :: TestTree
+acceptsWriteFirst = testCase "accepts members written before any read" do
+  g <- FG.newFrameGraph @Env @Env
+  a <- FG.addPass g "GenA" (FG.create @Tex "a" (tex "a") >>= FG.write) \_data -> pure ()
+  b <-
+    FG.addPass
+      g
+      "GenB"
+      (FG.read a >> (FG.create @Tex "b" (tex "b") >>= FG.write))
+      \_data -> pure ()
+  FG.addPass_ g "Use" (FG.read b >> FG.setSideEffect) (pure ())
+  FG.compile g
+  FG.validateAliasGroups g [[0, 1]]
+
+rejectsReadFirst :: TestTree
+rejectsReadFirst = testCase "rejects a member whose first access is a read" do
+  g <- FG.newFrameGraph @Env @Env
+  -- "b" is created bare, so its first read/write access is Use's read.
+  a <- FG.addPass g "GenA" (FG.create @Tex "a" (tex "a") >>= FG.write) \_data -> pure ()
+  b <- FG.addPass g "Bare" (FG.create @Tex "b" (tex "b")) \_data -> pure ()
+  FG.addPass_ g "Use" (FG.read a >> FG.read b >> FG.setSideEffect) (pure ())
+  FG.compile g
+  FG.validateAliasGroups g [[0]]
+  assertFatal $ FG.validateAliasGroups g [[0], [1]]
+
+renamingToucherReads :: TestTree
+renamingToucherReads = testCase "a renaming first toucher counts as a read" do
+  g <- FG.newFrameGraph @Env @Env
+  imp <- FG.importResource g "imp" (tex "imp") (Tex 7)
+  FG.addPass_ g "Blit" (FG.write_ imp) (pure ())
+  FG.compile g
+  -- The rename implicitly reads the prior version.
+  assertFatal $ FG.validateAliasGroups g [[0]]
+
+fatalBeforeCompileAndUnknown :: TestTree
+fatalBeforeCompileAndUnknown = testCase "fatal before compile and on unknown entries" do
+  g <- FG.newFrameGraph @Env @Env
+  FG.addPass_ g "Gen" (FG.create @Tex "a" (tex "a") >>= FG.write_ >> FG.setSideEffect) (pure ())
+  assertFatal $ FG.validateAliasGroups g [[0]]
+  FG.compile g
+  assertFatal $ FG.validateAliasGroups g [[42]]
diff --git a/test/Spec/Behavior.hs b/test/Spec/Behavior.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/Behavior.hs
@@ -0,0 +1,409 @@
+-- | The behaviour checklist: culling, lifetimes, hook ordering.
+module Spec.Behavior (tests) where
+
+import Control.Exception (evaluate)
+import Control.Monad (void)
+import Control.Monad.IO.Class (liftIO)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase, (@?=))
+
+import Fragr (QueueId (..))
+import Fragr qualified as FG
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "behavior checklist"
+    [ creationOrder
+    , importedWriteRenames
+    , liveRanges
+    , dependencyLevels
+    , scratchCulledWithoutDemand
+    , scratchSurvivesOnDemand
+    , renameCarriesFlags
+    , emptyPassCulled
+    , chainExecutes
+    , cullingIsTransitive
+    , creatorSurvivesBareRead
+    , sideEffectKeepsInputAlive
+    , execHooksBracketCallback
+    , sinkPassComposes
+    , finalizeKeepsChainAlive
+    ]
+
+creationOrder :: TestTree
+creationOrder = testCase "side-effect pass creates its transients in declaration order" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  (a, c) <-
+    FG.addPass
+      g
+      "P"
+      ( do
+          a <- FG.create @Tex "A" (tex "A")
+          c <- FG.create @Tex "B" (tex "B")
+          a' <- FG.write a
+          c' <- FG.write c
+          FG.setSideEffect
+          pure (a', c')
+      )
+      \(a, c) -> do
+        Tex 0 <- FG.get @Tex a
+        Tex 1 <- FG.get @Tex c
+        ranHere "P"
+  FG.compile g
+  FG.execute g env env
+  _ <- evaluate (a, c)
+  ev <- getEvents env
+  ev @?= [ECreate "A" 0, ECreate "B" 1, ERun "P", EDestroy "A", EDestroy "B"]
+
+importedWriteRenames :: TestTree
+importedWriteRenames = testCase "writing an imported resource renames the handle and forces a side effect" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h1 <- FG.importResource g "BB" (tex "BB") (Tex 42)
+  h2 <-
+    FG.addPass
+      g
+      "Blit"
+      (FG.write h1)
+      \h -> do
+        obj <- FG.get @Tex h
+        liftIO (obj @?= Tex 42) -- the imported object itself, not a fresh one
+        ranHere "Blit"
+  FG.isValid g h1 >>= \v -> v @?= False
+  FG.isValid g h2 >>= \v -> v @?= True
+  s <- FG.snapshot g
+  map (.sideEffect) s.passes @?= [True]
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  -- imported resources are never created nor destroyed by the graph
+  ev @?= [ERun "Blit"]
+
+liveRanges :: TestTree
+liveRanges = testCase "entry live ranges span the executing passes that touch them" do
+  g <- FG.newFrameGraph @Device @Device
+  -- A: creates a, B: a -> b, C: b -> c. 'a' dies at B, 'c' is born at C,
+  -- so their ranges are disjoint and the two may share memory.
+  a <- FG.addPass g "A" (FG.create @Image "a" (img "a") >>= FG.write) \_data -> pure ()
+  b <-
+    FG.addPass
+      g
+      "B"
+      (FG.read a >> (FG.create @Image "b" (img "b") >>= FG.write))
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "C"
+      (FG.read b >> FG.create @Image "c" (img "c") >>= FG.write_ >> FG.setSideEffect)
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  -- Entries in creation order (a, b, c); positions index the executing
+  -- passes (A=0, B=1, C=2).
+  map (.live) s.entries @?= [Just (0, 1), Just (1, 2), Just (2, 2)]
+  -- 'a' dies at B and 'c' is born at C: they never coexist, so a backend
+  -- owning the allocations could place them in the same memory.
+  assertBool "a and c are aliasable" $
+    case map (.live) s.entries of
+      [Just (_, aEnd), _, Just (cStart, _)] -> aEnd < cStart
+      _ -> False
+
+dependencyLevels :: TestTree
+dependencyLevels = testCase "dependency levels group the passes nothing orders" do
+  g <- FG.newFrameGraph @Device @Device
+  -- A and B produce independently, on different queues; C consumes both.
+  a <- FG.addPass g "A" (FG.create @Image "a" (img "a") >>= FG.write) \_data -> pure ()
+  b <-
+    FG.addPass
+      g
+      "B"
+      (FG.setQueue (QueueId 1) >> FG.create @Image "b" (img "b") >>= FG.write)
+      \_data -> pure ()
+  FG.addPass_ g "C" (FG.read a >> FG.read b >> FG.setSideEffect) (pure ())
+  -- Culled: nobody reads its output and it claims no side effect.
+  FG.addPass_ g "D" (void (FG.create @Image "d" (img "d") >>= FG.write)) (pure ())
+  FG.compile g
+  s <- FG.snapshot g
+  -- A and B hold distinct execution positions (0 and 1) yet share a
+  -- level: no edge orders them, so a backend may not read their
+  -- positions as an ordering. C sits a level below both; D is culled.
+  map (.level) s.passes @?= [Just 0, Just 0, Just 1, Nothing]
+
+scratchCulledWithoutDemand :: TestTree
+scratchCulledWithoutDemand = testCase "a scratch import's writer is culled without demand" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  scratch <- FG.importScratch g "S" (tex "S") (Tex 2)
+  _ <- FG.addPass g "Fx" (FG.write scratch) \_h -> ranHere "Fx"
+  s <- FG.snapshot g
+  map (.sideEffect) s.passes @?= [False]
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= []
+
+scratchSurvivesOnDemand :: TestTree
+scratchSurvivesOnDemand = testCase "a scratch import's writer survives on demand" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  scratch <- FG.importScratch g "S" (tex "S") (Tex 2)
+  written <- FG.addPass g "Fx" (FG.write scratch) \_h -> ranHere "Fx"
+  FG.addPass_ g "Probe" (FG.setSideEffect >> FG.read written) (ranHere "Probe")
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= [ERun "Fx", ERun "Probe"]
+
+renameCarriesFlags :: TestTree
+renameCarriesFlags = testCase "pass B renames pass A's resource; flags reach the hooks" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  hA <-
+    FG.addPass
+      g
+      "A"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.write h
+      )
+      \_data -> ranHere "A"
+  hB <-
+    FG.addPass
+      g
+      "B"
+      ( do
+          FG.readWith hA 7
+          h' <- FG.writeWith hA 9
+          FG.setSideEffect
+          pure h'
+      )
+      \_data -> ranHere "B"
+  FG.isValid g hA >>= \v -> v @?= False
+  FG.isValid g hB >>= \v -> v @?= True
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev
+    @?= [ ECreate "R" 0
+        , ERun "A"
+        , EPreRead "R" 7
+        , EPreWrite "R" 9
+        , ERun "B"
+        , EDestroy "R"
+        ]
+
+emptyPassCulled :: TestTree
+emptyPassCulled = testCase "a pass with no declarations and no side effect is culled" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  FG.addPass g "Nop" (pure ()) (\_data -> ranHere "Nop")
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= []
+
+chainExecutes :: TestTree
+chainExecutes = testCase "multi-pass chain executes fully, unrelated pass is culled" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  backbuffer <- FG.importResource g "backbuffer" (tex "backbuffer") (Tex 42)
+  hDepth <-
+    FG.addPass
+      g
+      "Depth"
+      ( do
+          h <- FG.create @Tex "depth" (tex "depth")
+          FG.write h
+      )
+      \_data -> ranHere "Depth"
+  hGbuf <-
+    FG.addPass
+      g
+      "GBuffer"
+      ( do
+          FG.read hDepth
+          h <- FG.create @Tex "gbuf" (tex "gbuf")
+          FG.write h
+      )
+      \_data -> ranHere "GBuffer"
+  _ <-
+    FG.addPass
+      g
+      "Lighting"
+      ( do
+          FG.read hGbuf
+          FG.write backbuffer
+      )
+      \_data -> ranHere "Lighting"
+  _ <-
+    FG.addPass
+      g
+      "Orphan"
+      ( do
+          h <- FG.create @Tex "scratch" (tex "scratch")
+          FG.write h
+      )
+      \_data -> ranHere "Orphan"
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev
+    @?= [ ECreate "depth" 0
+        , ERun "Depth"
+        , ECreate "gbuf" 1
+        , ERun "GBuffer"
+        , EDestroy "depth"
+        , ERun "Lighting"
+        , EDestroy "gbuf"
+        ]
+
+cullingIsTransitive :: TestTree
+cullingIsTransitive = testCase "culling is transitive through renamed versions" do
+  -- A writes v1, B renames it to v2, nobody reads v2: both die.
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  hA <-
+    FG.addPass
+      g
+      "A"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.write h
+      )
+      \_data -> ranHere "A"
+  _ <-
+    FG.addPass
+      g
+      "B"
+      (FG.write hA)
+      \_data -> ranHere "B"
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= []
+  s <- FG.snapshot g
+  map (.canExecute) s.passes @?= [False, False]
+
+creatorSurvivesBareRead :: TestTree
+creatorSurvivesBareRead = testCase "a creator pass survives when only its created version is read" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h <-
+    FG.addPass
+      g
+      "Author"
+      (FG.create @Tex "R" (tex "R"))
+      \_data -> ranHere "Author"
+  FG.addPass_
+    g
+    "Reader"
+    do
+      FG.read h
+      FG.setSideEffect
+    (ranHere "Reader")
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= [ECreate "R" 0, ERun "Author", ERun "Reader", EDestroy "R"]
+
+sideEffectKeepsInputAlive :: TestTree
+sideEffectKeepsInputAlive = testCase "a side-effecting pass with zero refCount keeps its input alive" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  hA <-
+    FG.addPass
+      g
+      "A"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.write h
+      )
+      \_data -> ranHere "A"
+  _ <-
+    FG.addPass
+      g
+      "ReadOnly"
+      ( do
+          FG.read hA
+          FG.setSideEffect
+      )
+      \_data -> ranHere "ReadOnly"
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  -- ReadOnly executes despite its zero refCount, so R must outlive it
+  -- (deliberately deeper than spec 4.3, which computes lifetimes over
+  -- refCount > 0 passes only and would destroy R right after A).
+  ev @?= [ECreate "R" 0, ERun "A", ERun "ReadOnly", EDestroy "R"]
+
+execHooksBracketCallback :: TestTree
+execHooksBracketCallback = testCase "exec hooks bracket the callback after the access hooks, composing in installation order" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  FG.addPreExec g \env' -> push env' (EFlush "adapter")
+  FG.addPreExec g \env' -> push env' (EFlush "app")
+  FG.addPostExec g \env' -> push env' (EPostFlush "adapter")
+  FG.addPostExec g \env' -> push env' (EPostFlush "app")
+  h <- FG.importResource g "R" (tex "R") (Tex 1)
+  FG.addPass_
+    g
+    "P"
+    ( do
+        FG.readWith h 3
+        FG.setSideEffect
+    )
+    (ranHere "P")
+  FG.compile g
+  FG.execute g env env
+  ev <- getEvents env
+  ev
+    @?= [ EPreRead "R" 3
+        , EFlush "adapter"
+        , EFlush "app"
+        , ERun "P"
+        , EPostFlush "adapter"
+        , EPostFlush "app"
+        ]
+
+sinkPassComposes :: TestTree
+sinkPassComposes = testCase "addPass_ and write_ compose a sink pass with zero discards" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h <- FG.importResource g "BB" (tex "BB") (Tex 1)
+  FG.addPass_
+    g
+    "Present"
+    (FG.writeWith_ h 6)
+    (ranHere "Present")
+  FG.compile g
+  s <- FG.snapshot g
+  -- writeWith_ renames like writeWith; the import still forces the
+  -- side effect.
+  FG.isValid g h >>= (@?= False)
+  map (.sideEffect) s.passes @?= [True]
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= [EPreWrite "BB" 6, ERun "Present"]
+
+finalizeKeepsChainAlive :: TestTree
+finalizeKeepsChainAlive = testCase "finalize keeps the chain alive and fires the terminal write hook" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h <-
+    FG.addPass
+      g
+      "A"
+      (FG.create @Tex "R" (tex "R") >>= FG.write)
+      \_data -> ranHere "A"
+  FG.finalize g h 8
+  FG.compile g
+  s <- FG.snapshot g
+  [fin] <- pure (filter (\p -> p.name == "finalize R") s.passes)
+  fin.sideEffect @?= True
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= [ECreate "R" 0, ERun "A", EPreWrite "R" 8, EDestroy "R"]
diff --git a/test/Spec/Dot.hs b/test/Spec/Dot.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/Dot.hs
@@ -0,0 +1,168 @@
+-- | Graphviz rendering of the graph and of its schedule.
+module Spec.Dot (tests) where
+
+import Control.Monad (void)
+import Data.Text qualified as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+import Fragr (QueueId (..))
+import Fragr qualified as FG
+import Fragr.Snapshot.Dot qualified as Dot
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "dot output"
+    [ rendersPassesAndVersions
+    , clusteredImports
+    , groupedImports
+    , stratifiedRanks
+    , antiEdges
+    , syncView
+    ]
+
+rendersPassesAndVersions :: TestTree
+rendersPassesAndVersions = testCase "renders passes, resources and versions" do
+  g <- FG.newFrameGraph @Env @Env
+  backbuffer <- FG.importResource g "backbuffer" (tex "backbuffer") (Tex 1)
+  hA <-
+    FG.addPass
+      g
+      "Draw"
+      ( do
+          h <- FG.create @Tex "color" (tex "color")
+          FG.write h
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Present"
+      ( do
+          FG.read hA
+          FG.write backbuffer
+      )
+      \_data -> pure ()
+  FG.addPass_ g "Culled" (pure ()) (pure ())
+  FG.compile g
+  out <- Dot.dump g
+  has out "digraph FrameGraph {"
+  has out "P0"
+  has out "Draw"
+  has out "R0_2" -- renamed backbuffer
+  has out "backbuffer v2"
+  has out "fillcolor=lightgray" -- the culled pass
+  has out "fillcolor=lightsteelblue" -- the initial import version
+  has out "fillcolor=steelblue" -- its written successor
+  assertBool "imports float by default" (not ("cluster_imported" `Text.isInfixOf` out))
+
+clusteredImports :: TestTree
+clusteredImports = testCase "opting in fences the imports in a cluster" do
+  g <- FG.newFrameGraph @Env @Env
+  backbuffer <- FG.importResource g "backbuffer" (tex "backbuffer") (Tex 1)
+  FG.addPass_ g "Present" (void (FG.write backbuffer)) (pure ())
+  FG.compile g
+  out <- Dot.dumpWith Dot.defaultOptions{Dot.clusterImports = True} g
+  has out "cluster_imported"
+  has out "label=\"Imported\""
+
+groupedImports :: TestTree
+groupedImports = testCase "imports sharing a dotted prefix group into one record node" do
+  g <- FG.newFrameGraph @Env @Env
+  m0 <- FG.importResource g "img.mip0" (tex "img.mip0") (Tex 0)
+  m1 <- FG.importResource g "img.mip1" (tex "img.mip1") (Tex 1)
+  solo <- FG.importResource g "shadow" (tex "shadow") (Tex 2)
+  FG.addPass_
+    g
+    "Read"
+    do
+      FG.read m0
+      FG.read m1
+      FG.read solo
+      FG.setSideEffect
+    (pure ())
+  FG.compile g
+  out <- Dot.dump g
+  -- One family vertex with a port per member, edges through the ports.
+  has out "G0 [label=\"{img|{<n0> mip0|<n1> mip1}}\""
+  has out "G0:n0 -> { P0 }"
+  has out "G0:n1 -> { P0 }"
+  -- The undotted import keeps its own vertex; the members lose theirs.
+  has out "R2_1 [label=\"{shadow"
+  assertBool "no standalone mip0 vertex" (not ("R0_1 [" `Text.isInfixOf` out))
+  assertBool "no standalone mip1 vertex" (not ("R1_1 [" `Text.isInfixOf` out))
+
+stratifiedRanks :: TestTree
+stratifiedRanks = testCase "stratifying ranks the passes by dependency level" do
+  g <- FG.newFrameGraph @Env @Env
+  -- Two independent producers (level 0), one consumer of both (level 1).
+  hA <- FG.addPass g "GenA" (FG.create @Tex "a" (tex "a") >>= FG.write) \_d -> pure ()
+  hB <- FG.addPass g "GenB" (FG.create @Tex "b" (tex "b") >>= FG.write) \_d -> pure ()
+  FG.addPass_ g "Combine" (FG.read hA >> FG.read hB >> FG.setSideEffect) (pure ())
+  FG.addPass_ g "Culled" (pure ()) (pure ())
+  FG.compile g
+  out <- Dot.dumpWith Dot.defaultOptions{Dot.stratify = True} g
+  has out "{ rank=same; P0 P1 }"
+  has out "{ rank=same; P2 }"
+  assertBool "the culled pass is unpinned" (not ("P3 }" `Text.isInfixOf` out))
+  off <- Dot.dump g
+  assertBool "ranks are off by default" (not ("rank=same" `Text.isInfixOf` off))
+
+antiEdges :: TestTree
+antiEdges = testCase "anti-edges overlay the reader a later write supersedes" do
+  g <- FG.newFrameGraph @Env @Env
+  backbuffer <- FG.importResource g "backbuffer" (tex "backbuffer") (Tex 1)
+  FG.addPass_ g "Sample" (FG.read backbuffer >> FG.setSideEffect) (pure ())
+  FG.addPass_ g "Overwrite" (FG.write_ backbuffer >> FG.setSideEffect) (pure ())
+  FG.compile g
+  out <- Dot.dump g
+  -- Sample reads v1, Overwrite renames it to v2: no dataflow, but an order.
+  assertBool "reader -> renamer edge" ("P0 -> P1 [style=dashed" `Text.isInfixOf` out)
+  off <- Dot.dumpWith Dot.defaultOptions{Dot.antiEdges = False} g
+  assertBool "anti-edges can be turned off" (not ("dashed" `Text.isInfixOf` off))
+
+syncView :: TestTree
+syncView = testCase "the sync view renders queue lanes, waits, transfers and retires" do
+  g <- FG.newFrameGraph @Device @Device
+  gbuf <-
+    FG.addPass
+      g
+      "Graphics"
+      (FG.create @Image "gbuf" (img "gbuf") >>= (`FG.writeWith` ColorAttachment))
+      \_data -> pure ()
+  lit <-
+    FG.addPass
+      g
+      "Compute"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.readWith gbuf ShaderRead
+          FG.create @Image "lit" (img "lit") >>= (`FG.writeWith` General)
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Present"
+      (FG.readWith lit ShaderRead >> FG.setSideEffect)
+      \_data -> pure ()
+  FG.addPass_ g "Culled" (pure ()) (pure ())
+  FG.compile g
+  out <- Dot.dumpSync g
+  has out "digraph FrameGraphSync {"
+  -- One lane per queue, chained in submission order.
+  has out "cluster_q0"
+  has out "label=\"queue 1\""
+  has out "P0 [label=\"{Graphics|signal 1}\""
+  has out "P0 -> P2 [color=gray"
+  -- The cross-queue wait edge, scoped by its covers.
+  has out "P0 -> P1 [label=\">=1\\ngbuf @ShaderRead\", color=royalblue]"
+  -- Release / acquire pairs render as transfer edges.
+  has out "P0 -> P1 [label=\"gbuf @ShaderRead\", color=purple]"
+  has out "P1 -> P2 [label=\"lit @ShaderRead\", color=purple]"
+  -- Retire notes hang off the last toucher.
+  has out "T0 [shape=note, label=\"retire gbuf\\nq0>=1, q1>=1\", fillcolor=khaki]"
+  has out "P1 -> T0"
+  assertBool "culled passes are absent" (not ("Culled" `Text.isInfixOf` out))
diff --git a/test/Spec/Error.hs b/test/Spec/Error.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/Error.hs
@@ -0,0 +1,127 @@
+-- | Misuse that must be fatal rather than silently wrong.
+module Spec.Error (tests) where
+
+import Control.Monad (void)
+import Data.Coerce (coerce)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase)
+
+import Fragr (Handle (..))
+import Fragr qualified as FG
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "error model"
+    [ staleRead
+    , staleWrite
+    , readOwnCreate
+    , readOwnWrite
+    , wrongResourceType
+    , undeclaredGet
+    , outOfRangeHandle
+    , executeBeforeCompile
+    ]
+
+staleRead :: TestTree
+staleRead = testCase "reading a stale handle is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  h1 <- FG.importResource g "BB" (tex "BB") (Tex 1)
+  FG.addPass_ g "P" (FG.write_ h1) (pure ())
+  assertFatal $ FG.addPass_ g "Q" (FG.read h1) (pure ())
+
+staleWrite :: TestTree
+staleWrite = testCase "writing a stale handle is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  h1 <- FG.importResource g "BB" (tex "BB") (Tex 1)
+  FG.addPass_ g "P" (FG.write_ h1) (pure ())
+  assertFatal $ FG.addPass_ g "Q" (FG.write_ h1) (pure ())
+
+readOwnCreate :: TestTree
+readOwnCreate = testCase "reading a handle the pass created is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  assertFatal $
+    FG.addPass
+      g
+      "P"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.read h
+      )
+      \_data -> pure ()
+
+readOwnWrite :: TestTree
+readOwnWrite = testCase "reading a handle the pass wrote is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  h1 <- FG.importResource g "BB" (tex "BB") (Tex 1)
+  assertFatal $
+    FG.addPass
+      g
+      "P"
+      ( do
+          h2 <- FG.write h1
+          FG.read h2
+      )
+      \_data -> pure ()
+
+wrongResourceType :: TestTree
+wrongResourceType = testCase "get with the wrong resource type is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  _ <-
+    FG.addPass
+      g
+      "P"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.write_ h
+          FG.setSideEffect
+          pure h
+      )
+      -- Honest handles carry their resource type; forging a
+      -- wrongly-typed one takes a 'coerce'. The runtime guard
+      -- still catches it.
+      \h -> void (FG.get @Buf (coerce h))
+  FG.compile g
+  assertFatal $ FG.execute g env env
+
+undeclaredGet :: TestTree
+undeclaredGet = testCase "get on an undeclared handle is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h <-
+    FG.addPass
+      g
+      "P"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.write_ h
+          FG.setSideEffect
+          pure h
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Q"
+      ( do
+          FG.read h
+          FG.setSideEffect
+      )
+      \_data ->
+        -- Q reads h, but this forged handle was never declared.
+        void (FG.get @Tex (Handle 99))
+  FG.compile g
+  assertFatal $ FG.execute g env env
+
+outOfRangeHandle :: TestTree
+outOfRangeHandle = testCase "out-of-range handles are fatal, not merely invalid" do
+  g <- FG.newFrameGraph @Env @Env
+  assertFatal $ FG.isValid g (Handle 99)
+
+executeBeforeCompile :: TestTree
+executeBeforeCompile = testCase "execute before compile is fatal" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  assertFatal $ FG.execute g env env
diff --git a/test/Spec/JSON.hs b/test/Spec/JSON.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/JSON.hs
@@ -0,0 +1,93 @@
+-- | JSON rendering of the graph for the interactive viewer.
+module Spec.JSON (tests) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import Fragr qualified as FG
+import Fragr.Snapshot.JSON (ViewerGraph (..), ViewerPass (..), ViewerResource (..))
+import Fragr.Snapshot.JSON qualified as Json
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "json output"
+    [ renderDocument
+    , dumpsCompiledGraph
+    ]
+
+renderDocument :: TestTree
+renderDocument = testCase "render emits the viewer document, omitting empty resource fields" do
+  let graph =
+        ViewerGraph
+          { passes =
+              [ ViewerPass{id = 0, name = "Draw", culled = False, reads = [], writes = [1]}
+              , ViewerPass{id = 1, name = "Culled", culled = True, reads = [], writes = []}
+              ]
+          , resources =
+              [ ViewerResource
+                  { id = 0
+                  , name = "backbuffer"
+                  , description = "a \"quoted\"\ntitle"
+                  , transient = False
+                  , createdBy = Nothing
+                  , readers = []
+                  , writers = [1]
+                  }
+              , ViewerResource
+                  { id = 1
+                  , name = "color"
+                  , description = ""
+                  , transient = True
+                  , createdBy = Just 0
+                  , readers = [1]
+                  , writers = [0]
+                  }
+              ]
+          }
+  Json.render graph
+    @?= mconcat
+      [ "{\"passes\":["
+      , "{\"id\":0,\"name\":\"Draw\",\"culled\":false,\"reads\":[],\"writes\":[1]},"
+      , "{\"id\":1,\"name\":\"Culled\",\"culled\":true,\"reads\":[],\"writes\":[]}"
+      , "],\"resources\":["
+      , "{\"id\":0,\"name\":\"backbuffer\",\"description\":\"a \\\"quoted\\\"\\u000atitle\",\"transient\":false,\"writers\":[1]},"
+      , "{\"id\":1,\"name\":\"color\",\"description\":\"\",\"transient\":true,\"createdBy\":0,\"readers\":[1],\"writers\":[0]}"
+      , "]}"
+      ]
+
+dumpsCompiledGraph :: TestTree
+dumpsCompiledGraph = testCase "dump projects the compiled graph" do
+  g <- FG.newFrameGraph @Env @Env
+  backbuffer <- FG.importResource g "backbuffer" (tex "backbuffer") (Tex 1)
+  hA <-
+    FG.addPass
+      g
+      "Draw"
+      ( do
+          h <- FG.create @Tex "color" (tex "color")
+          FG.write h
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Present"
+      ( do
+          FG.read hA
+          FG.write backbuffer
+      )
+      \_data -> pure ()
+  FG.addPass_ g "Skipped" (pure ()) (pure ())
+  FG.compile g
+  out <- Json.dump g
+  has out "{\"passes\":[{\"id\":0,\"name\":\"Draw\",\"culled\":false,\"reads\":[],\"writes\":[1]}"
+  -- Writing the import is a read-modify-write: backbuffer shows in reads too.
+  has out "{\"id\":1,\"name\":\"Present\",\"culled\":false,\"reads\":[1,0],\"writes\":[0]}"
+  has out "{\"id\":2,\"name\":\"Skipped\",\"culled\":true"
+  -- The transient carries its creator; the whole rename chain lands on one record.
+  has out "\"name\":\"color\""
+  has out "\"transient\":true,\"createdBy\":0,\"readers\":[1],\"writers\":[0]}"
+  has out "\"name\":\"backbuffer\""
+  has out "\"transient\":false,\"readers\":[1],\"writers\":[1]}"
diff --git a/test/Spec/MultiQueue.hs b/test/Spec/MultiQueue.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/MultiQueue.hs
@@ -0,0 +1,833 @@
+-- | Cross-queue scheduling: waits, events, hand-offs and reclamation.
+module Spec.MultiQueue (tests) where
+
+import Fragr
+
+import Control.Exception (try)
+import Control.Monad (void)
+import Data.Foldable (find, for_)
+import Data.IORef (modifyIORef', newIORef, readIORef)
+import Data.Maybe (fromJust, isJust)
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
+
+import Fragr qualified as FG
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "multi-queue"
+    [ crossQueueHandoff
+    , watermarkDedup
+    , droppedWaitCoversMigrate
+    , nonAdjacentEventPair
+    , adjacentNeedsNoEvent
+    , writeAfterForeignRead
+    , crossQueueRenameFlags
+    , recycleAwaitsTimelines
+    , recycleAwaitsRefcount
+    , culledContributeNothing
+    , perQueueCommandLogs
+    , finalizeFollowsProducerQueue
+    , finalizeFollowsBareCreator
+    , executingQueuesAreLive
+    , queuedWithoutRecycleImport
+    , queuedWithoutRecycleTransient
+    , familyFanOut
+    , siblingGuardWithinFamily
+    , sharedSkipsSiblingGuard
+    , twoFamiliesRejected
+    , markSharedExemptsFanOut
+    , sharedImportByContract
+    , queueOutsidePartition
+    , sameFamilyStillPairs
+    , mixedFamilyFanOutRejected
+    , sameQueueSharesAcquire
+    , renameCarriesNoTransferBack
+    , foreignImportDerivesNothing
+    , ownedImportBoundaryPair
+    , ownedImportAtHome
+    , ownedImportForeignRename
+    , ownedImportUnused
+    ]
+
+crossQueueHandoff :: TestTree
+crossQueueHandoff = testCase "graphics produces, compute (other queue) consumes: one cross-queue wait, release/acquire" do
+  g <- FG.newFrameGraph @Device @Device
+  gbuf <-
+    FG.addPass
+      g
+      "Graphics"
+      ( do
+          h <- FG.create @Image "gbuf" (img "gbuf")
+          FG.write h
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Compute"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read gbuf
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  let
+    graphics = syncOf s "Graphics"
+    compute = syncOf s "Compute"
+  compute.waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}]
+  compute.acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+  graphics.releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
+  graphics.signal @?= 1
+  dev <- runQueued g
+  l0 <- queueLog dev (QueueId 0)
+  l1 <- queueLog dev (QueueId 1)
+  assertBool "release recorded on q0" (("release " <> tshow gbuf <> " to q1") `elem` l0)
+  assertBool "acquire recorded on q1" (("acquire " <> tshow gbuf <> " from q0") `elem` l1)
+  assertBool "wait recorded on q1" ("wait q0>=1" `elem` l1)
+
+watermarkDedup :: TestTree
+watermarkDedup = testCase "watermark dedup: the later consumer inherits the earlier wait" do
+  g <- FG.newFrameGraph @Device @Device
+  r1 <- FG.addPass g "A1" (FG.create @Image "r1" (img "r1") >>= FG.write) \_data -> pure ()
+  r2 <- FG.addPass g "A2" (FG.create @Image "r2" (img "r2") >>= FG.write) \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "C1"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read r1
+          FG.read r2
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "C2"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read r1
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  -- C1 waits only on the later value (2), covering both edges' reads.
+  (syncOf s "C1").waits @?= [Wait{queue = QueueId 0, value = 2, covers = [Access{handle = r1, flags = Nothing}, Access{handle = r2, flags = Nothing}]}]
+  -- C2's need (q0 >= 1) is already implied by C1's earlier wait.
+  (syncOf s "C2").waits @?= []
+
+droppedWaitCoversMigrate :: TestTree
+droppedWaitCoversMigrate = testCase "a dropped wait's covers migrate to the kept wait" do
+  g <- FG.newFrameGraph @Device @Device
+  r1 <- FG.addPass g "A1" (FG.create @Image "r1" (img "r1") >>= FG.write) \_data -> pure ()
+  r2 <- FG.addPass g "A2" (FG.create @Image "r2" (img "r2") >>= FG.write) \_data -> pure ()
+  -- C1's wait (value 2) sets the watermark; C2's (value 1) is dropped,
+  -- but its differently-flagged access must still scope the kept wait.
+  _ <-
+    FG.addPass
+      g
+      "C1"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.readWith r2 ColorAttachment
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "C2"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.readWith r1 ShaderRead
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  (syncOf s "C1").waits
+    @?= [ Wait
+            { queue = QueueId 0
+            , value = 2
+            , covers = [Access{handle = r1, flags = Just ShaderRead}, Access{handle = r2, flags = Just ColorAttachment}]
+            }
+        ]
+  (syncOf s "C2").waits @?= []
+
+nonAdjacentEventPair :: TestTree
+nonAdjacentEventPair = testCase "same-queue non-adjacent dependency emits an event pair" do
+  g <- FG.newFrameGraph @Device @Device
+  r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Middle"
+      ( do
+          h <- FG.create @Image "m" (img "m")
+          FG.write_ h
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Consumer"
+      ( do
+          FG.read r
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  (syncOf s "Producer").signalEvents @?= [SyncEvent{event = EventId 0, covers = [Access{handle = r, flags = Nothing}]}]
+  (syncOf s "Consumer").waitEvents @?= [SyncEvent{event = EventId 0, covers = [Access{handle = r, flags = Nothing}]}]
+  (syncOf s "Middle").signalEvents @?= []
+  (syncOf s "Middle").waitEvents @?= []
+
+adjacentNeedsNoEvent :: TestTree
+adjacentNeedsNoEvent = testCase "adjacent same-queue dependency needs no event" do
+  g <- FG.newFrameGraph @Device @Device
+  r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Consumer"
+      ( do
+          FG.read r
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  (syncOf s "Producer").signalEvents @?= []
+  (syncOf s "Consumer").waitEvents @?= []
+
+writeAfterForeignRead :: TestTree
+writeAfterForeignRead = testCase "a renaming writer waits for foreign-queue readers (write-after-read)" do
+  g <- FG.newFrameGraph @Device @Device
+  r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Reader"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read r
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  r' <-
+    FG.addPass
+      g
+      "Overwrite"
+      ( do
+          r' <- FG.write r
+          FG.setSideEffect
+          pure r'
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  -- Reader holds position 1 on queue 1; Overwrite must not run before
+  -- it, even though no data flows from Reader to Overwrite. The wait
+  -- covers the renaming write the anti-edge protects.
+  (syncOf s "Overwrite").waits @?= [Wait{queue = QueueId 1, value = 1, covers = [Access{handle = r', flags = Nothing}]}]
+
+crossQueueRenameFlags :: TestTree
+crossQueueRenameFlags = testCase "a cross-queue rename's transfer carries the renaming write's access" do
+  g <- FG.newFrameGraph @Device @Device
+  r <-
+    FG.addPass
+      g
+      "Producer"
+      (FG.create @Image "r" (img "r") >>= (`FG.writeWith` ColorAttachment))
+      \_data -> runHere "Producer"
+  r' <-
+    FG.addPass
+      g
+      "Rename"
+      ( do
+          FG.setQueue (QueueId 1)
+          r' <- FG.writeWith r General
+          FG.setSideEffect
+          pure r'
+      )
+      \_data -> runHere "Rename"
+  FG.compile g
+  s <- FG.snapshot g
+  -- The rename consumes r through its implicit flagless read; the
+  -- transfer carries the renaming write's access instead — so the
+  -- release / acquire hooks fire, with the target state.
+  (syncOf s "Producer").releases @?= [Transfer{handle = r', peer = QueueId 1, flags = Just General}]
+  (syncOf s "Rename").acquires @?= [Transfer{handle = r', peer = QueueId 0, flags = Just General}]
+  dev <- runQueued g
+  l0 <- queueLog dev (QueueId 0)
+  l1 <- queueLog dev (QueueId 1)
+  assertBool "release hook fires with the write's flags" ("release-hook r ->general" `elem` l0)
+  assertBool "acquire hook fires with the write's flags" ("acquire-hook r ->general" `elem` l1)
+
+recycleAwaitsTimelines :: TestTree
+recycleAwaitsTimelines = testCase "recycle queue reclaims only once every timeline is past the requirements" do
+  rq <- FG.newRecycleQueue
+  destroyed <- newIORef ([] :: [Int])
+  item <- FG.mkRetireItem 7 [(QueueId 0, 2), (QueueId 1, 3)] (modifyIORef' destroyed (7 :))
+  FG.retireItem rq item
+  c1 <- FG.collect rq [(QueueId 0, 2), (QueueId 1, 2)] -- q1 short
+  c1 @?= []
+  readIORef destroyed >>= (@?= [])
+  c2 <- FG.collect rq [(QueueId 0, 5), (QueueId 1, 3)] -- both met
+  c2 @?= [7]
+  readIORef destroyed >>= (@?= [7])
+
+recycleAwaitsRefcount :: TestTree
+recycleAwaitsRefcount = testCase "an in-use refcount holds a resource past satisfied timelines" do
+  rq <- FG.newRecycleQueue
+  destroyed <- newIORef (0 :: Int)
+  item <- FG.mkRetireItem 1 [(QueueId 0, 1)] (modifyIORef' destroyed (+ 1))
+  FG.acquireItem item
+  FG.retireItem rq item
+  c1 <- FG.collect rq [(QueueId 0, 1)] -- timelines met, but a ref is held
+  c1 @?= []
+  readIORef destroyed >>= (@?= 0)
+  FG.releaseItem item
+  c2 <- FG.collect rq [(QueueId 0, 1)]
+  c2 @?= [1]
+  readIORef destroyed >>= (@?= 1)
+
+culledContributeNothing :: TestTree
+culledContributeNothing = testCase "culled passes contribute nothing to the schedule" do
+  g <- FG.newFrameGraph @Device @Device
+  _ <-
+    FG.addPass
+      g
+      "Live"
+      ( do
+          h <- FG.create @Image "r" (img "r")
+          FG.write_ h
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.addPass_ g "Dead" (FG.create @Image "s" (img "s") >>= FG.write_) (pure ())
+  FG.compile g
+  s <- FG.snapshot g
+  [live] <- pure (filter (\p -> p.name == "Live") s.passes)
+  [dead] <- pure (filter (\p -> p.name == "Dead") s.passes)
+  assertBool "the live pass has a schedule" (isJust live.sync)
+  dead.sync @?= Nothing
+  -- Only the live resource (entry 0) shows up in the retire requirements.
+  map (.entryId) s.retires @?= [0]
+
+perQueueCommandLogs :: TestTree
+perQueueCommandLogs = testCase "a multi-queue frame records the expected per-queue command logs" do
+  g <- FG.newFrameGraph @Device @Device
+  FG.addPreExec g \dev -> logHere dev "flush"
+  FG.addPostExec g \dev -> logHere dev "post-flush"
+  gbuf <-
+    FG.addPass
+      g
+      "Graphics"
+      ( do
+          h <- FG.create @Image "gbuf" (img "gbuf")
+          FG.writeWith h ColorAttachment
+      )
+      \_data -> runHere "Graphics"
+  lit <-
+    FG.addPass
+      g
+      "Compute"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.readWith gbuf ShaderRead
+          h <- FG.create @Image "lit" (img "lit")
+          FG.writeWith h General
+      )
+      \_data -> runHere "Compute"
+  _ <-
+    FG.addPass
+      g
+      "Present"
+      ( do
+          FG.readWith lit ShaderRead
+          FG.setSideEffect
+      )
+      \_data -> runHere "Present"
+  FG.compile g
+  FG.executingQueues g >>= (@?= [QueueId 0, QueueId 1])
+  s <- FG.snapshot g
+  -- The schedule carries the consuming access's flags on waits and
+  -- transfers, so drivers can derive wait scopes and target states.
+  (syncOf s "Compute").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Just ShaderRead}]}]
+  (syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Just ShaderRead}]
+  (syncOf s "Present").acquires @?= [Transfer{handle = lit, peer = QueueId 1, flags = Just ShaderRead}]
+  dev <- runQueued g
+  l0 <- queueLog dev (QueueId 0)
+  l1 <- queueLog dev (QueueId 1)
+  l0
+    @?= [ "create gbuf"
+        , "barrier gbuf undefined->color-attachment"
+        , "flush"
+        , "run Graphics"
+        , "release-hook gbuf ->shader-read"
+        , "post-flush"
+        , "release " <> tshow gbuf <> " to q1"
+        , "signal 1"
+        , "wait q1>=1"
+        , "acquire " <> tshow lit <> " from q1"
+        , "acquire-hook lit ->shader-read"
+        , "barrier lit general->shader-read"
+        , "flush"
+        , "run Present"
+        , "post-flush"
+        , "signal 2"
+        , "destroy lit"
+        ]
+  l1
+    @?= [ "wait q0>=1"
+        , "acquire " <> tshow gbuf <> " from q0"
+        , "create lit"
+        , "acquire-hook gbuf ->shader-read"
+        , "barrier gbuf color-attachment->shader-read"
+        , "barrier lit undefined->general"
+        , "flush"
+        , "run Compute"
+        , "release-hook lit ->shader-read"
+        , "post-flush"
+        , "release " <> tshow lit <> " to q0"
+        , "signal 1"
+        , "destroy gbuf"
+        ]
+
+finalizeFollowsProducerQueue :: TestTree
+finalizeFollowsProducerQueue = testCase "finalize places its pass on the producer's queue" do
+  g <- FG.newFrameGraph @Device @Device
+  r <-
+    FG.addPass
+      g
+      "Compute"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.create @Image "r" (img "r") >>= FG.write
+      )
+      \_data -> pure ()
+  FG.finalize g r General
+  FG.compile g
+  FG.executingQueues g >>= (@?= [QueueId 1])
+  s <- FG.snapshot g
+  let fin = syncOf s "finalize r"
+  fin.queue @?= QueueId 1
+  -- Same queue, adjacent: the helper manufactures no cross-queue
+  -- wait, transfer or event.
+  fin.waits @?= []
+  fin.acquires @?= []
+
+finalizeFollowsBareCreator :: TestTree
+finalizeFollowsBareCreator = testCase "finalize follows a creator that never writes" do
+  g <- FG.newFrameGraph @Device @Device
+  r <-
+    FG.addPass
+      g
+      "Author"
+      do
+        FG.setQueue (QueueId 1)
+        FG.create @Image "r" (img "r")
+      \_data -> pure ()
+  FG.finalize g r General
+  FG.compile g
+  FG.executingQueues g >>= (@?= [QueueId 1])
+  s <- FG.snapshot g
+  (syncOf s "finalize r").queue @?= QueueId 1
+
+executingQueuesAreLive :: TestTree
+executingQueuesAreLive = testCase "executingQueues lists only queues with surviving passes" do
+  g <- FG.newFrameGraph @Device @Device
+  assertFatal $ FG.executingQueues g
+  _ <-
+    FG.addPass
+      g
+      "Live"
+      ( do
+          FG.setQueue (QueueId 1)
+          h <- FG.create @Image "r" (img "r")
+          FG.write_ h
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Dead"
+      ( do
+          FG.setQueue (QueueId 2)
+          FG.create @Image "s" (img "s") >>= FG.write
+      )
+      \_data -> pure ()
+  FG.compile g
+  FG.executingQueues g >>= (@?= [QueueId 1])
+
+queuedWithoutRecycleImport :: TestTree
+queuedWithoutRecycleImport = testCase "executeQueued without a recycle queue drives an import-only graph" do
+  g <- FG.newFrameGraph @Device @Device
+  swapImg <- Image <$> newIORef "undefined" <*> pure False
+  h <- FG.importResource g "swap" (img "swap") swapImg
+  _ <-
+    FG.addPass
+      g
+      "Blit"
+      (FG.writeWith h General)
+      \_data -> runHere "Blit"
+  FG.compile g
+  dev <- newDevice
+  FG.executeQueued g (mkBackend dev) Nothing dev dev
+  assertHandoffsDrained dev
+  l0 <- queueLog dev (QueueId 0)
+  l0 @?= ["barrier swap undefined->general", "run Blit", "signal 1"]
+
+queuedWithoutRecycleTransient :: TestTree
+queuedWithoutRecycleTransient = testCase "executeQueued without a recycle queue refuses a transient to reclaim" do
+  g <- FG.newFrameGraph @Device @Device
+  _ <-
+    FG.addPass
+      g
+      "P"
+      ( do
+          h <- FG.create @Image "t" (img "t")
+          FG.write_ h
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  dev <- newDevice
+  assertFatal $ FG.executeQueued g (mkBackend dev) Nothing dev dev
+
+familyFanOut :: TestTree
+familyFanOut = testCase "family fan-out: one transfer, acquired by the family's first consumer" do
+  (g, gbuf) <- fanOutGraph
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
+  (syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+  (syncOf s "C2").acquires @?= []
+  -- The sibling waits on the producer /and/ on the primary's acquiring
+  -- pass — the acquire barrier lives on the primary's queue.
+  (syncOf s "C2").waits
+    @?= [ Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}
+        , Wait{queue = QueueId 1, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}
+        ]
+  dev <- runQueued g
+  l1 <- queueLog dev (QueueId 1)
+  l2 <- queueLog dev (QueueId 2)
+  assertBool "the primary acquires" (("acquire " <> tshow gbuf <> " from q0") `elem` l1)
+  assertBool "the sibling does not" (("acquire " <> tshow gbuf <> " from q0") `notElem` l2)
+  assertBool "the sibling waits on the acquirer" ("wait q1>=1" `elem` l2)
+
+siblingGuardWithinFamily :: TestTree
+siblingGuardWithinFamily = testCase "the sibling guard melts within the producer's own family" do
+  -- Consumers of the producer's family need no acquire barrier: the
+  -- hand-off transitions producer-side, so the data-edge wait on the
+  -- producer already orders the sibling.
+  g <- FG.newFrameGraph @Device @Device
+  gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
+  for_ [(1 :: Int, "C1"), (2, "C2")] \(q, n) ->
+    FG.addPass
+      g
+      n
+      ( do
+          FG.setQueue (QueueId q)
+          FG.read gbuf
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 0), (QueueId 2, FamilyId 0)] g
+  s <- FG.snapshot g
+  (syncOf s "C2").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}]
+  void (runQueued g)
+
+sharedSkipsSiblingGuard :: TestTree
+sharedSkipsSiblingGuard = testCase "a shared resource skips the sibling guard" do
+  -- CONCURRENT: the release transitions producer-side and the acquire
+  -- melts, so the sibling's wait on the producer covers everything.
+  (g, gbuf) <- fanOutGraph
+  FG.markShared g gbuf
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "C2").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = gbuf, flags = Nothing}]}]
+  void (runQueued g)
+
+twoFamiliesRejected :: TestTree
+twoFamiliesRejected = testCase "released to two families is rejected" do
+  (g, _) <- fanOutGraph
+  expectTwoFamilies [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 2)] [("C1", FamilyId 1), ("C2", FamilyId 2)] g
+
+markSharedExemptsFanOut :: TestTree
+markSharedExemptsFanOut = testCase "markShared exempts a two-family fan-out from single ownership" do
+  (g, gbuf) <- fanOutGraph
+  FG.markShared g gbuf
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 2)] g
+  s <- FG.snapshot g
+  (syncOf s "Graphics").releases
+    @?= [ Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}
+        , Transfer{handle = gbuf, peer = QueueId 2, flags = Nothing}
+        ]
+  (syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+  (syncOf s "C2").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+
+sharedImportByContract :: TestTree
+sharedImportByContract = testCase "a shared import is exempt through the contract (isShared)" do
+  g <- FG.newFrameGraph @Device @Device
+  obj <- Image <$> newIORef "undefined" <*> pure True
+  h0 <- FG.importScratch g "gbuf" (img "gbuf") obj
+  gbuf <- FG.addPass g "Graphics" (FG.write h0) \_data -> pure ()
+  for_ [(1 :: Int, "C1"), (2, "C2")] \(q, n) ->
+    FG.addPass
+      g
+      n
+      ( do
+          FG.setQueue (QueueId q)
+          FG.read gbuf
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1), (QueueId 2, FamilyId 2)] g
+  s <- FG.snapshot g
+  (syncOf s "Graphics").releases
+    @?= [ Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}
+        , Transfer{handle = gbuf, peer = QueueId 2, flags = Nothing}
+        ]
+
+queueOutsidePartition :: TestTree
+queueOutsidePartition = testCase "a queue outside the partition keeps its per-queue transfer" do
+  (g, gbuf) <- fanOutGraph
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "Graphics").releases
+    @?= [ Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}
+        , Transfer{handle = gbuf, peer = QueueId 2, flags = Nothing}
+        ]
+  (syncOf s "C2").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+
+sameFamilyStillPairs :: TestTree
+sameFamilyStillPairs = testCase "a same-family consumer still gets the transfer pair (backends melt it)" do
+  g <- FG.newFrameGraph @Device @Device
+  gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "C1"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read gbuf
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 0)] g
+  s <- FG.snapshot g
+  (syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
+  (syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+
+mixedFamilyFanOutRejected :: TestTree
+mixedFamilyFanOutRejected = testCase "a fan-out mixing the producer's own family with a foreign one is rejected" do
+  -- C1 shares the producer's family, C2 does not. Ownership must move
+  -- to C2's family while C1 still reads on the source family — an order
+  -- the release (recorded producer-side) cannot express, so this is a
+  -- two-family violation like any other; markShared is the way out.
+  (g, _) <- fanOutGraph
+  expectTwoFamilies [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 0), (QueueId 2, FamilyId 1)] [("C1", FamilyId 0), ("C2", FamilyId 1)] g
+
+sameQueueSharesAcquire :: TestTree
+sameQueueSharesAcquire = testCase "same-queue consumers share one acquire" do
+  -- The partition-less path groups per (version, queue) like the
+  -- family path groups per family: one release must be consumed by
+  -- exactly one acquire, on the first-registered consumer.
+  g <- FG.newFrameGraph @Device @Device
+  gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
+  for_ [("C1" :: Text), "C2"] \n ->
+    FG.addPass
+      g
+      n
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read gbuf
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  (syncOf s "Graphics").releases @?= [Transfer{handle = gbuf, peer = QueueId 1, flags = Nothing}]
+  (syncOf s "C1").acquires @?= [Transfer{handle = gbuf, peer = QueueId 0, flags = Nothing}]
+  (syncOf s "C2").acquires @?= []
+  void (runQueued g)
+
+renameCarriesNoTransferBack :: TestTree
+renameCarriesNoTransferBack = testCase "a rename after a foreign read carries no transfer back" do
+  -- The reader's hand-off pair is the read edge's; the write-after-read
+  -- anti-edge carries no payload and the rename's data edge is
+  -- same-queue, so ownership is never handed back — safe only because
+  -- the overwrite is a write (backends acquire by discarding; a partial
+  -- cross-family rewrite is Q3's territory).
+  g <- FG.newFrameGraph @Device @Device
+  r <- FG.addPass g "Producer" (FG.create @Image "r" (img "r") >>= FG.write) \_data -> pure ()
+  _ <-
+    FG.addPass
+      g
+      "Reader"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read r
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  r' <-
+    FG.addPass
+      g
+      "Overwrite"
+      ( do
+          r' <- FG.write r
+          FG.setSideEffect
+          pure r'
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "Producer").releases @?= [Transfer{handle = r, peer = QueueId 1, flags = Nothing}]
+  (syncOf s "Reader").acquires @?= [Transfer{handle = r, peer = QueueId 0, flags = Nothing}]
+  (syncOf s "Overwrite").acquires @?= []
+  (syncOf s "Overwrite").releases @?= []
+  (syncOf s "Overwrite").waits @?= [Wait{queue = QueueId 1, value = 1, covers = [Access{handle = r', flags = Nothing}]}]
+  void (runQueued g)
+
+foreignImportDerivesNothing :: TestTree
+foreignImportDerivesNothing = testCase "an import first touched by a foreign queue derives no transfer" do
+  -- No producer edge this frame, so nothing arms a hand-off: a plain
+  -- EXCLUSIVE import cannot migrate families across frames (the
+  -- backend guards the read fatally) — declare the owner with
+  -- importOwned instead.
+  g <- FG.newFrameGraph @Device @Device
+  obj <- Image <$> newIORef "undefined" <*> pure False
+  h <- FG.importResource g "ext" (img "ext") obj
+  _ <-
+    FG.addPass
+      g
+      "Foreign"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.read h
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "Foreign").waits @?= []
+  (syncOf s "Foreign").acquires @?= []
+
+ownedImportBoundaryPair :: TestTree
+ownedImportBoundaryPair = testCase "an owned import hands its first foreign touch a boundary pair" do
+  -- The synthetic producer stands in for last frame's work on the
+  -- owning queue: the release records there, the consumer acquires
+  -- under its own flags and waits on the synthetic pass's signal.
+  (g, h) <- ownedImport (QueueId 0)
+  _ <-
+    FG.addPass
+      g
+      "Foreign"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.readWith h ShaderRead
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "import ext").releases @?= [Transfer{handle = h, peer = QueueId 1, flags = Just ShaderRead}]
+  (syncOf s "Foreign").acquires @?= [Transfer{handle = h, peer = QueueId 0, flags = Just ShaderRead}]
+  (syncOf s "Foreign").waits @?= [Wait{queue = QueueId 0, value = 1, covers = [Access{handle = h, flags = Just ShaderRead}]}]
+  dev <- runQueued g
+  l0 <- queueLog dev (QueueId 0)
+  assertBool "the release records on the owner" (("release " <> tshow h <> " to q1") `elem` l0)
+  assertBool "the release hook fires there" ("release-hook ext ->shader-read" `elem` l0)
+
+ownedImportAtHome :: TestTree
+ownedImportAtHome = testCase "an owned import first touched at home derives no transfer" do
+  (g, h) <- ownedImport (QueueId 0)
+  _ <- FG.addPass g "Consumer" (FG.readWith h ShaderRead >> FG.setSideEffect) \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
+  s <- FG.snapshot g
+  (syncOf s "import ext").releases @?= []
+  (syncOf s "Consumer").waits @?= []
+  (syncOf s "Consumer").acquires @?= []
+  void (runQueued g)
+
+ownedImportForeignRename :: TestTree
+ownedImportForeignRename = testCase "a foreign rename of an owned import carries the write's flags" do
+  -- The rename consumes the synthetic version through its implicit
+  -- flagless read; the pair rides the renaming write's access. The
+  -- writer also proves the observed flip: no setSideEffect, yet it
+  -- survives culling.
+  (g, h) <- ownedImport (QueueId 0)
+  h' <-
+    FG.addPass
+      g
+      "Overwrite"
+      ( do
+          FG.setQueue (QueueId 1)
+          FG.writeWith h ColorAttachment
+      )
+      \_data -> pure ()
+  FG.compileWith [(QueueId 0, FamilyId 0), (QueueId 1, FamilyId 1)] g
+  s <- FG.snapshot g
+  -- The pair names the renaming write's access — the new version's
+  -- handle, like any rename-consumed transfer.
+  (syncOf s "import ext").releases @?= [Transfer{handle = h', peer = QueueId 1, flags = Just ColorAttachment}]
+  (syncOf s "Overwrite").acquires @?= [Transfer{handle = h', peer = QueueId 0, flags = Just ColorAttachment}]
+  void (runQueued g)
+
+ownedImportUnused :: TestTree
+ownedImportUnused = testCase "an unused owned import culls its synthetic pass" do
+  (g, _) <- ownedImport (QueueId 0)
+  FG.compile g
+  s <- FG.snapshot g
+  (fromJust (find (\p -> p.name == "import ext") s.passes)).sync @?= Nothing
+  dev <- runQueued g
+  queueLog dev (QueueId 0) >>= (@?= [])
+
+-- | An image persisted from "last frame" (general layout), owned by the queue.
+ownedImport :: QueueId -> IO (FrameGraph Device Device, Handle Image)
+ownedImport owner = do
+  g <- FG.newFrameGraph @Device @Device
+  obj <- Image <$> newIORef "general" <*> pure False
+  h <- FG.importOwned g "ext" (img "ext") obj owner
+  pure (g, h)
+
+-- | One producer on queue 0, consumers on queues 1 and 2.
+fanOutGraph :: IO (FrameGraph Device Device, Handle Image)
+fanOutGraph = do
+  g <- FG.newFrameGraph @Device @Device
+  gbuf <- FG.addPass g "Graphics" (FG.create @Image "gbuf" (img "gbuf") >>= FG.write) \_data -> pure ()
+  for_ [(1 :: Int, "C1"), (2, "C2")] \(q, n) ->
+    FG.addPass
+      g
+      n
+      ( do
+          FG.setQueue (QueueId q)
+          FG.read gbuf
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  pure (g, gbuf)
+
+-- | Compiling under the partition must reject @gbuf@ with these consumers.
+expectTwoFamilies :: [(QueueId, FamilyId)] -> [(Text, FamilyId)] -> FrameGraph Device Device -> IO ()
+expectTwoFamilies partition consumers g =
+  try @FragrError (FG.compileWith partition g) >>= \case
+    Left (ReleasedToTwoFamilies res cs) -> do
+      res @?= "gbuf"
+      cs @?= consumers
+    Left e -> assertFailure ("unexpected error: " <> show e)
+    Right () -> assertFailure "expected ReleasedToTwoFamilies"
diff --git a/test/Spec/Setup.hs b/test/Spec/Setup.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/Setup.hs
@@ -0,0 +1,87 @@
+-- | What the setup phase records before compilation.
+module Spec.Setup (tests) where
+
+import Control.Monad.IO.Class (liftIO)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import Fragr (Access (..))
+import Fragr qualified as FG
+import Utils
+
+tests :: TestTree
+tests =
+  testGroup
+    "setup phase bookkeeping"
+    [ duplicateReadsSuppressed
+    , renameMintsNode
+    , implicitReadRecorded
+    , descriptorLookup
+    ]
+
+duplicateReadsSuppressed :: TestTree
+duplicateReadsSuppressed = testCase "exact duplicate reads are suppressed, different flags are not" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h <- FG.importResource g "R" (tex "R") (Tex 1)
+  _ <-
+    FG.addPass
+      g
+      "B"
+      ( do
+          FG.readWith h 3
+          FG.readWith h 3
+          FG.readWith h 4
+          FG.setSideEffect
+      )
+      \_data -> pure ()
+  FG.compile g
+  s <- FG.snapshot g
+  [node] <- pure (filter (\n -> n.name == "R") s.nodes)
+  node.refCount @?= 2
+  FG.execute g env env
+  ev <- getEvents env
+  ev @?= [EPreRead "R" 3, EPreRead "R" 4]
+
+renameMintsNode :: TestTree
+renameMintsNode = testCase "rename bumps the entry version and mints a new node" do
+  g <- FG.newFrameGraph @Env @Env
+  h1 <- FG.importResource g "BB" (tex "BB") (Tex 1)
+  FG.addPass_ g "P" (FG.write_ h1) (pure ())
+  s <- FG.snapshot g
+  map (.version) s.nodes @?= [1, 2]
+  map (.resourceId) s.nodes @?= [0, 0]
+  map (.name) s.nodes @?= ["BB", "BB"]
+  map (.version) s.entries @?= [2]
+
+implicitReadRecorded :: TestTree
+implicitReadRecorded = testCase "the implicit read of a rename is recorded without flags" do
+  g <- FG.newFrameGraph @Env @Env
+  h1 <- FG.importResource g "BB" (tex "BB") (Tex 1)
+  h2 <- FG.addPass g "P" (FG.writeWith h1 5) \_data -> pure ()
+  s <- FG.snapshot g
+  [p] <- pure s.passes
+  p.reads @?= [Access{handle = h1, flags = Nothing}]
+  p.writes @?= [Access{handle = h2, flags = Just 5}]
+
+descriptorLookup :: TestTree
+descriptorLookup = testCase "getDescriptor works on the graph and through the accessor" do
+  g <- FG.newFrameGraph @Env @Env
+  env <- newEnv
+  h <-
+    FG.addPass
+      g
+      "P"
+      ( do
+          h <- FG.create @Tex "R" (tex "R")
+          FG.write_ h
+          FG.setSideEffect
+          pure h
+      )
+      \h -> do
+        d <- FG.getDesc @Tex h
+        liftIO (d @?= tex "R")
+  d <- FG.getDescriptor @Tex g h
+  d @?= tex "R"
+  FG.compile g
+  FG.execute g env env
diff --git a/test/Utils.hs b/test/Utils.hs
new file mode 100644
--- /dev/null
+++ b/test/Utils.hs
@@ -0,0 +1,345 @@
+{-# LANGUAGE TypeFamilies #-}
+
+-- | Test resource types and the mock backends the suites drive them through.
+module Utils
+  ( -- * Event-log resources
+    Env (..)
+  , newEnv
+  , Event (..)
+  , push
+  , getEvents
+  , ran
+  , ranHere
+  , Tex (..)
+  , TexDesc (..)
+  , tex
+  , Buf (..)
+
+    -- * Simulated device
+  , Device (..)
+  , newDevice
+  , logQ
+  , logHere
+  , queueLog
+  , Image (..)
+  , ImgDesc (..)
+  , Layout (..)
+  , layoutName
+  , img
+  , runDevice
+  , runHere
+
+    -- * Driving a frame
+  , mkBackend
+  , runQueued
+  , assertHandoffsDrained
+
+    -- * Assertions
+  , assertFatal
+  , has
+  , syncOf
+  , tshow
+  ) where
+
+import Fragr
+
+import Control.Exception (try)
+import Control.Monad (unless, when)
+import Control.Monad.IO.Class (liftIO)
+import Data.Foldable (find, for_)
+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
+import Data.IntMap.Strict (IntMap)
+import Data.IntMap.Strict qualified as IntMap
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Maybe (fromJust)
+import Data.Set (Set)
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Word (Word64)
+import Test.Tasty.HUnit (assertBool, assertFailure)
+
+import Fragr qualified as FG
+
+-- * Test resource types
+
+{- | Everything observable funnels into one event log; the allocator also
+carries a creation counter (checklist: "observable via a creation
+counter in the test resource type").
+-}
+data Env = Env
+  { counter :: IORef Int
+  , events :: IORef [Event]
+  }
+
+newEnv :: IO Env
+newEnv = Env <$> newIORef 0 <*> newIORef []
+
+data Event
+  = ECreate Text Int
+  | EDestroy Text
+  | EPreRead Text Word64
+  | EPreWrite Text Word64
+  | EFlush Text
+  | EPostFlush Text
+  | ERun Text
+  deriving stock (Eq, Show)
+
+push :: Env -> Event -> IO ()
+push env e = modifyIORef' env.events (e :)
+
+getEvents :: Env -> IO [Event]
+getEvents env = reverse <$> readIORef env.events
+
+ran :: Env -> Text -> IO ()
+ran env passName = push env (ERun passName)
+
+-- | 'ran' from inside an execution callback.
+ranHere :: Text -> FG.Exec Env Env ()
+ranHere passName = do
+  env <- FG.askCtx
+  liftIO (ran env passName)
+
+newtype Tex = Tex Int
+  deriving stock (Eq, Show)
+
+data TexDesc = TexDesc
+  { tag :: Text
+  , size :: Int
+  }
+  deriving stock (Eq, Show)
+
+instance FG.Resource Tex where
+  type Desc Tex = TexDesc
+  type Alloc Tex = Env
+  type Ctx Tex = Env
+  type Flags Tex = Word64
+
+  createResource desc env = do
+    n <- atomicModifyIORef' env.counter \n -> (n + 1, n)
+    push env (ECreate desc.tag n)
+    pure (Tex n)
+
+  destroyResource desc env _ = push env (EDestroy desc.tag)
+
+  preRead _ desc w env _ = push env (EPreRead desc.tag w)
+
+  preWrite _ desc w env _ = push env (EPreWrite desc.tag w)
+
+  describeDesc desc = desc.tag
+
+{- | A second resource type, for type-mismatch tests. No hooks: relies on
+the default no-op 'preRead' / 'preWrite', empty 'describeDesc' and unit
+'Flags'.
+-}
+data Buf = Buf
+  deriving stock (Eq, Show)
+
+instance FG.Resource Buf where
+  type Desc Buf = Int
+  type Alloc Buf = Env
+  type Ctx Buf = Env
+  createResource _ _ = pure Buf
+  destroyResource _ _ _ = pure ()
+
+tex :: Text -> TexDesc
+tex t = TexDesc{tag = t, size = 4}
+
+assertFatal :: IO a -> IO ()
+assertFatal act =
+  try @FragrError (act >> pure ()) >>= \case
+    Left _ -> pure ()
+    Right () -> assertFailure "expected a FragrError"
+
+-- | Assert the rendered output contains the needle.
+has :: Text -> Text -> IO ()
+has out needle = assertBool (Text.unpack needle <> " in output") (needle `Text.isInfixOf` out)
+
+-- * Simulated Vulkan-like backend (test-only, no GPU vocabulary in src/)
+
+{- | A mock multi-queue device: per-queue command logs, per-queue timeline
+counters, a set of signaled events, and the queue currently recording (so
+the layout-tracking image hooks know where to log their barriers).
+-}
+data Device = Device
+  { devLog :: IORef (IntMap [Text])
+  -- ^ queue -> reversed command log
+  , devTimeline :: IORef (IntMap Word64)
+  -- ^ queue -> highest signaled (== reached, since we run synchronously)
+  , devEvents :: IORef (Set Int)
+  , devQueue :: IORef QueueId
+  , devHandoffs :: IORef (Map (Text, Int) (Int, Word64))
+  -- ^ (handle, dst queue) -> (src queue, its signal): released, not yet acquired
+  , devWaited :: IORef (Map (Int, Int) Word64)
+  -- ^ (queue, foreign queue) -> the highest value it has waited for so far
+  }
+
+newDevice :: IO Device
+newDevice =
+  Device
+    <$> newIORef IntMap.empty
+    <*> newIORef IntMap.empty
+    <*> newIORef Set.empty
+    <*> newIORef (QueueId 0)
+    <*> newIORef Map.empty
+    <*> newIORef Map.empty
+
+logQ :: Device -> QueueId -> Text -> IO ()
+logQ dev (QueueId q) msg = modifyIORef' dev.devLog (IntMap.insertWith (++) q [msg])
+
+logHere :: Device -> Text -> IO ()
+logHere dev msg = do
+  q <- readIORef dev.devQueue
+  logQ dev q msg
+
+queueLog :: Device -> QueueId -> IO [Text]
+queueLog dev (QueueId q) = reverse . IntMap.findWithDefault [] q <$> readIORef dev.devLog
+
+{- | A simulated image whose layout state lives in an 'IORef'; the
+'FG.preRead' / 'FG.preWrite' hooks diff requested-vs-current and record a
+barrier command, exactly like a real backend would.
+-}
+data Image = Image (IORef Text) Bool
+
+newtype ImgDesc = ImgDesc Text
+
+-- | Image layout states: a real ADT reaching the hooks, no bit packing.
+data Layout = ShaderRead | ColorAttachment | General
+  deriving stock (Eq, Ord, Show)
+
+layoutName :: Layout -> Text
+layoutName = \case
+  ShaderRead -> "shader-read"
+  ColorAttachment -> "color-attachment"
+  General -> "general"
+
+instance FG.Resource Image where
+  type Desc Image = ImgDesc
+  type Alloc Image = Device
+  type Ctx Image = Device
+  type Flags Image = Layout
+
+  createResource (ImgDesc n) dev = do
+    logHere dev ("create " <> n)
+    Image <$> newIORef "undefined" <*> pure False
+
+  destroyResource (ImgDesc n) dev _ = logHere dev ("destroy " <> n)
+
+  preRead _ d f dev im = barrier d f dev im
+  preWrite _ d f dev im = barrier d f dev im
+
+  isShared (Image _ sh) = sh
+
+  preAcquire _ (ImgDesc n) f _peer dev _ = logHere dev ("acquire-hook " <> n <> " ->" <> layoutName f)
+  preRelease _ (ImgDesc n) f _peer dev _ = logHere dev ("release-hook " <> n <> " ->" <> layoutName f)
+
+barrier :: ImgDesc -> Layout -> Device -> Image -> IO ()
+barrier (ImgDesc n) f dev (Image stateRef _) = do
+  cur <- readIORef stateRef
+  let target = layoutName f
+  when (cur /= target) do
+    logHere dev ("barrier " <> n <> " " <> cur <> "->" <> target)
+    writeIORef stateRef target
+
+img :: Text -> ImgDesc
+img = ImgDesc
+
+{- | A 'QueueBackend' that records the schedule onto the mock device.
+
+Hand-offs are ground-truthed ('devHandoffs'): a release arms (handle, dst)
+exactly once, the acquire consumes it exactly once from the right peer, and
+only after a wait covering the releasing pass's signal — the invariants a
+real QFOT pair needs to not deadlock or corrupt. 'assertHandoffsDrained'
+closes the frame: every release found its acquire.
+-}
+mkBackend :: Device -> QueueBackend
+mkBackend dev =
+  QueueBackend
+    { invoke = \_ body -> body
+    , beforePass = \ps -> do
+        writeIORef dev.devQueue ps.queue
+        let QueueId our = ps.queue
+        for_ ps.waits \w -> do
+          let QueueId q = w.queue
+          tl <- readIORef dev.devTimeline
+          let reached = IntMap.findWithDefault 0 q tl
+          when (reached < w.value) $
+            assertFailure ("wait on q" <> show q <> " for " <> show w.value <> " but only " <> show reached <> " reached")
+          modifyIORef' dev.devWaited (Map.insertWith max (our, q) w.value)
+          logQ dev ps.queue ("wait q" <> tshow q <> ">=" <> tshow w.value)
+        for_ ps.waitEvents \se -> do
+          let EventId e = se.event
+          evs <- readIORef dev.devEvents
+          unless (Set.member e evs) $
+            assertFailure ("waited on unsignaled event " <> show e)
+          logQ dev ps.queue ("waitEvent " <> tshow e)
+        waited <- readIORef dev.devWaited
+        for_ ps.acquires \(Transfer h peer _flags) -> do
+          let QueueId src = peer
+          Map.lookup (tshow h, our) <$> readIORef dev.devHandoffs >>= \case
+            Nothing ->
+              assertFailure ("acquire of " <> show h <> " on q" <> show our <> " without a pending release")
+            Just (src', sig) -> do
+              when (src' /= src) $
+                assertFailure ("acquire of " <> show h <> " names q" <> show src <> " but q" <> show src' <> " released it")
+              let covered = Map.findWithDefault 0 (our, src) waited
+              when (covered < sig) $
+                assertFailure ("acquire of " <> show h <> " precedes a wait covering its release (waited " <> show covered <> ", released at " <> show sig <> ")")
+              modifyIORef' dev.devHandoffs (Map.delete (tshow h, our))
+          logQ dev ps.queue ("acquire " <> tshow h <> " from q" <> tshow src)
+    , afterPass = \ps -> do
+        let QueueId our = ps.queue
+        for_ ps.releases \(Transfer h peer _flags) -> do
+          let QueueId dst = peer
+          pend <- readIORef dev.devHandoffs
+          when (Map.member (tshow h, dst) pend) $
+            assertFailure ("double release of " <> show h <> " to q" <> show dst)
+          modifyIORef' dev.devHandoffs (Map.insert (tshow h, dst) (our, ps.signal))
+          logQ dev ps.queue ("release " <> tshow h <> " to q" <> tshow dst)
+        for_ ps.signalEvents \se -> do
+          let EventId e = se.event
+          modifyIORef' dev.devEvents (Set.insert e)
+          logQ dev ps.queue ("signalEvent " <> tshow e)
+        let QueueId q = ps.queue
+        modifyIORef' dev.devTimeline (IntMap.insert q ps.signal)
+        logQ dev ps.queue ("signal " <> tshow ps.signal)
+    , completed = do
+        tl <- readIORef dev.devTimeline
+        pure do
+          (q, v) <- IntMap.toList tl
+          pure (QueueId q, v)
+    }
+
+-- | Every release found its acquire; call after 'FG.executeQueued'.
+assertHandoffsDrained :: Device -> IO ()
+assertHandoffsDrained dev = do
+  pend <- readIORef dev.devHandoffs
+  unless (Map.null pend) $
+    assertFailure ("released but never acquired: " <> show (Map.keys pend))
+
+-- | Execute on a fresh mock device and close the frame's hand-off ledger.
+runQueued :: FrameGraph Device Device -> IO Device
+runQueued g = do
+  dev <- newDevice
+  rq <- FG.newRecycleQueue
+  FG.executeQueued g (mkBackend dev) (Just rq) dev dev
+  assertHandoffsDrained dev
+  pure dev
+
+runDevice :: Device -> Text -> IO ()
+runDevice dev n = logHere dev ("run " <> n)
+
+-- | 'runDevice' from inside an execution callback.
+runHere :: Text -> FG.Exec Device Device ()
+runHere n = do
+  dev <- FG.askCtx
+  liftIO (runDevice dev n)
+
+syncOf :: Snapshot -> Text -> PassSync
+syncOf s n = fromJust do
+  p <- find (\p -> p.name == n) s.passes
+  p.sync
+
+tshow :: (Show a) => a -> Text
+tshow = Text.pack . show
