diff --git a/seihou-core.cabal b/seihou-core.cabal
--- a/seihou-core.cabal
+++ b/seihou-core.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: seihou-core
-version: 0.5.0.0
+version: 0.6.0.0
 synopsis: Core library for Seihou project scaffolding
 description:
   Core library for Seihou, a composable project scaffolding system.
@@ -24,10 +24,10 @@
 library
   default-language: GHC2024
   default-extensions:
+    DeriveAnyClass
     DuplicateRecordFields
     NoFieldSelectors
     OverloadedLabels
-    OverloadedRecordDot
     OverloadedStrings
     TypeFamilies
 
@@ -40,6 +40,8 @@
     Seihou.Composition.Resolve
     Seihou.Core.AgentPrompt
     Seihou.Core.Application
+    Seihou.Core.ArtifactOriginDetect
+    Seihou.Core.ArtifactRef
     Seihou.Core.Blueprint
     Seihou.Core.CommandFingerprint
     Seihou.Core.CommandVar
@@ -133,10 +135,10 @@
   type: exitcode-stdio-1.0
   default-language: GHC2024
   default-extensions:
+    DeriveAnyClass
     DuplicateRecordFields
     NoFieldSelectors
     OverloadedLabels
-    OverloadedRecordDot
     OverloadedStrings
 
   hs-source-dirs: test
@@ -149,6 +151,8 @@
     Seihou.Composition.ResolveSpec
     Seihou.Core.AgentPromptSpec
     Seihou.Core.ApplicationSpec
+    Seihou.Core.ArtifactOriginDetectSpec
+    Seihou.Core.ArtifactRefSpec
     Seihou.Core.BlueprintSpec
     Seihou.Core.CommandFingerprintSpec
     Seihou.Core.CommandVarSpec
@@ -212,7 +216,9 @@
     directory >=1.3 && <2,
     effectful-core >=2.4 && <3,
     filepath >=1.4 && <2,
+    generic-lens >=2.2 && <3,
     hspec >=2.11 && <3,
+    lens >=5.2 && <6,
     seihou-core,
     tasty >=1.4 && <2,
     tasty-hspec >=1.2 && <2,
diff --git a/src/Seihou/Composition/Graph.hs b/src/Seihou/Composition/Graph.hs
--- a/src/Seihou/Composition/Graph.hs
+++ b/src/Seihou/Composition/Graph.hs
@@ -5,6 +5,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Seihou.Composition.Instance (ModuleInstance (..), mkInstance)
@@ -18,17 +19,17 @@
 -- 'ParentVars' have independent edges, so the topological sort
 -- produces one node per distinct invocation.
 data CompositionGraph = CompositionGraph
-  { cgModules :: Map ModuleInstance Module,
-    cgEdges :: Map ModuleInstance [ModuleInstance]
+  { modules :: !(Map ModuleInstance Module),
+    edges :: !(Map ModuleInstance [ModuleInstance])
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Build a composition graph from a list of module instances.
 --
 -- Each module's dependencies are resolved to the corresponding
 -- 'ModuleInstance' present in the input. A dependency edge with
--- @depVars@ selects the instance created with those exact bindings;
--- a bare dependency (no @depVars@) selects the 'emptyParentVars'
+-- @vars@ selects the instance created with those exact bindings;
+-- a bare dependency (no @vars@) selects the 'emptyParentVars'
 -- instance. If the instance set does not contain the child the edge
 -- points to, the edge is silently dropped — the loader is
 -- responsible for ensuring every referenced child is loaded first.
@@ -36,18 +37,18 @@
 buildGraph entries =
   let present = Set.fromList (map fst entries)
       edgesFor m =
-        -- Dedupe edges: if a parent lists the same @(depModule, depVars)@
+        -- Dedupe edges: if a parent lists the same @(module_, vars)@
         -- twice, the two edges resolve to the same child instance and
         -- must count as one for the topological sort's in-degree.
         Set.toAscList . Set.fromList $
           [ child
-          | dep <- m.dependencies,
-            let child = mkInstance dep.depModule (parentVarsFromDep dep),
+          | dep <- m ^. #dependencies,
+            let child = mkInstance (dep ^. #module_) (parentVarsFromDep dep),
             Set.member child present
           ]
    in CompositionGraph
-        { cgModules = Map.fromList entries,
-          cgEdges = Map.fromList [(inst, edgesFor m) | (inst, m) <- entries]
+        { modules = Map.fromList entries,
+          edges = Map.fromList [(inst, edgesFor m) | (inst, m) <- entries]
         }
 
 -- | Topological sort using Kahn's algorithm, operating on
@@ -62,12 +63,12 @@
 topoSort graph = kahn initialReady initialInDegree [] allNodes
   where
     allNodes :: Set ModuleInstance
-    allNodes = Map.keysSet graph.cgEdges
+    allNodes = Map.keysSet (graph ^. #edges)
 
     initialInDegree :: Map ModuleInstance Int
     initialInDegree =
       Map.fromList
-        [ (n, length [d | d <- Map.findWithDefault [] n graph.cgEdges, Set.member d allNodes])
+        [ (n, length [d | d <- Map.findWithDefault [] n (graph ^. #edges), Set.member d allNodes])
         | n <- Set.toList allNodes
         ]
 
@@ -82,7 +83,7 @@
     kahn [] _ result remaining
       | Set.null remaining = Right (reverse result)
       | otherwise =
-          Left (CircularDependency (map (.instanceModule) (Set.toList remaining)))
+          Left (CircularDependency (map (^. #module_) (Set.toList remaining)))
     kahn (node : rest) inDeg result remaining =
       let remaining' = Set.delete node remaining
           (newReady, inDeg') = foldl (decrementDep node) ([], inDeg) (Set.toList remaining')
@@ -90,7 +91,7 @@
 
     decrementDep :: ModuleInstance -> ([ModuleInstance], Map ModuleInstance Int) -> ModuleInstance -> ([ModuleInstance], Map ModuleInstance Int)
     decrementDep processed (ready, inDeg) candidate =
-      let deps = Map.findWithDefault [] candidate graph.cgEdges
+      let deps = Map.findWithDefault [] candidate (graph ^. #edges)
        in if processed `elem` deps
             then
               let newDeg = Map.findWithDefault 0 candidate inDeg - 1
diff --git a/src/Seihou/Composition/Instance.hs b/src/Seihou/Composition/Instance.hs
--- a/src/Seihou/Composition/Instance.hs
+++ b/src/Seihou/Composition/Instance.hs
@@ -9,6 +9,7 @@
 
 import Crypto.Hash.SHA256 qualified as SHA256
 import Data.ByteString.Base16 qualified as Base16
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Data.Text.Encoding qualified as TE
@@ -27,15 +28,15 @@
 -- one instance. See @docs/plans/10-parameterized-dep-multi-instantiation.md@
 -- for the full rationale.
 data ModuleInstance = ModuleInstance
-  { instanceModule :: ModuleName,
-    instanceParentVars :: ParentVars
+  { module_ :: !ModuleName,
+    parentVars :: !ParentVars
   }
-  deriving stock (Eq, Ord, Show)
+  deriving stock (Eq, Generic, Ord, Show)
 
 -- | Build a 'ModuleInstance' from a module name and the parent-supplied
 -- bindings along the edge that reached it.
 mkInstance :: ModuleName -> ParentVars -> ModuleInstance
-mkInstance n pv = ModuleInstance {instanceModule = n, instanceParentVars = pv}
+mkInstance n pv = ModuleInstance {module_ = n, parentVars = pv}
 
 -- | The 'ModuleInstance' for a top-level (primary / CLI-additional /
 -- recipe-expanded) module, which receives no parent-supplied bindings.
@@ -59,13 +60,13 @@
 -- 'ModuleName' alongside the bindings so output stays readable.
 qualifiedName :: ModuleInstance -> ModuleName
 qualifiedName inst =
-  case Map.null inst.instanceParentVars.unParentVars of
-    True -> inst.instanceModule
+  case Map.null (inst ^. #parentVars . #unParentVars) of
+    True -> (inst ^. #module_)
     False ->
       ModuleName $
-        inst.instanceModule.unModuleName
+        inst ^. #module_ . #unModuleName
           <> "#"
-          <> stableHash inst.instanceParentVars
+          <> stableHash (inst ^. #parentVars)
 
 -- | Compute the disambiguating hash for a 'ParentVars' set.
 --
diff --git a/src/Seihou/Composition/Plan.hs b/src/Seihou/Composition/Plan.hs
--- a/src/Seihou/Composition/Plan.hs
+++ b/src/Seihou/Composition/Plan.hs
@@ -9,6 +9,7 @@
 import Data.Aeson qualified as Aeson
 import Data.Aeson.Encode.Pretty qualified as AesonPretty
 import Data.Aeson.KeyMap qualified as KM
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -47,16 +48,16 @@
           instancedModule =
             Module
               { name = qn,
-                version = m.version,
-                description = m.description,
-                vars = m.vars,
-                exports = m.exports,
-                prompts = m.prompts,
-                steps = m.steps,
-                commands = m.commands,
-                dependencies = m.dependencies,
-                removal = m.removal,
-                migrations = m.migrations
+                version = m ^. #version,
+                description = m ^. #description,
+                vars = m ^. #vars,
+                exports = m ^. #exports,
+                prompts = m ^. #prompts,
+                steps = m ^. #steps,
+                commands = m ^. #commands,
+                dependencies = m ^. #dependencies,
+                removal = m ^. #removal,
+                migrations = m ^. #migrations
               }
       result <- compilePlan dir instancedModule vars
       case result of
diff --git a/src/Seihou/Composition/Recipe.hs b/src/Seihou/Composition/Recipe.hs
--- a/src/Seihou/Composition/Recipe.hs
+++ b/src/Seihou/Composition/Recipe.hs
@@ -3,6 +3,8 @@
   )
 where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict (Map)
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
@@ -17,14 +19,14 @@
 --
 -- The first module in the recipe's list becomes the primary module (used for
 -- config namespace derivation). All remaining modules become additional modules.
--- Variable overrides are collected from each module entry's @depVars@ bindings.
+-- Variable overrides are collected from each module entry's @vars@ bindings.
 expandRecipe :: Recipe -> Either [Text] ExpandedRecipe
 expandRecipe recipe = do
   validated <- validateRecipe recipe
-  case validated.modules of
+  case validated ^. #modules of
     [] -> Left ["recipe must list at least one module"]
     primary : additional ->
-      let primaryName = primary.depModule
-          additionalNames = map (.depModule) additional
-          overrides = Map.unions (map (.depVars) validated.modules)
-       in Right (primaryName, additionalNames, overrides, validated.vars, validated.prompts)
+      let primaryName = (primary ^. #module_)
+          additionalNames = map (^. #module_) additional
+          overrides = Map.unions (map (^. #vars) (validated ^. #modules))
+       in Right (primaryName, additionalNames, overrides, validated ^. #vars, validated ^. #prompts)
diff --git a/src/Seihou/Composition/Resolve.hs b/src/Seihou/Composition/Resolve.hs
--- a/src/Seihou/Composition/Resolve.hs
+++ b/src/Seihou/Composition/Resolve.hs
@@ -12,6 +12,7 @@
 where
 
 import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -39,7 +40,7 @@
 -- Returns modules with their directories in execution order (dependencies first).
 --
 -- Each entry carries a 'ModuleInstance' identifying the exact invocation.
--- Two dependency edges to the same module with different @depVars@ produce
+-- Two dependency edges to the same module with different @vars@ produce
 -- two distinct entries; identical edges dedupe.
 loadComposition ::
   [FilePath] ->
@@ -48,11 +49,11 @@
   IO (Either ModuleLoadError [(ModuleInstance, Module, FilePath)])
 loadComposition searchPaths primary additional = runExceptT $ do
   (primaryMod, primaryDir) <- ExceptT $ loadModuleWithDir searchPaths primary
-  let effectiveDeps = primaryMod.dependencies ++ map simpleDep additional
-      effectivePrimary = primaryMod {dependencies = nubOrdBy (.depModule) effectiveDeps}
+  let effectiveDeps = primaryMod ^. #dependencies ++ map simpleDep additional
+      effectivePrimary = (primaryMod & #dependencies .~ nubOrdBy (^. #module_) effectiveDeps)
       primaryInst = primaryInstance primary
       loaded = Map.singleton primaryInst (effectivePrimary, primaryDir)
-      seeds = [(mkInstance dep.depModule (parentVarsFromDep dep)) | dep <- effectivePrimary.dependencies]
+      seeds = [(mkInstance (dep ^. #module_) (parentVarsFromDep dep)) | dep <- effectivePrimary ^. #dependencies]
   allInstances <- ExceptT $ loadTransitive searchPaths loaded seeds
   let entries = [(inst, m) | (inst, (m, _)) <- Map.toList allInstances]
       graph = buildGraph entries
@@ -111,11 +112,11 @@
     go _ [] perModule _ = Right perModule
     go parentVarsMap ((inst, m, _dir) : rest) perModule allExports = do
       let visibleExports = gatherEdgeExports m allExports
-          adjustedDecls = map (injectExportDefault visibleExports) m.vars
+          adjustedDecls = map (injectExportDefault visibleExports) (m ^. #vars)
           myParentVars = Map.findWithDefault Map.empty inst parentVarsMap
           saved = Map.findWithDefault Map.empty inst savedValues
       resolved <- resolveVariablesWithSaved adjustedDecls cliOverrides saved envVars namespace context localConfig nsConfig ctxConfig globalConfig myParentVars
-      let declaredNames = Set.fromList (map (.name) m.vars)
+      let declaredNames = Set.fromList (map (^. #name) (m ^. #vars))
           inherited =
             Map.mapWithKey
               makeInheritedResolved
@@ -183,12 +184,12 @@
     goPrompt _ _ [] perModule _ = pure (Right perModule)
     goPrompt interactive parentVarsMap ((inst, m, _dir) : rest) perModule allExports = do
       let visibleExports = gatherEdgeExports m allExports
-          adjustedDecls = map (injectExportDefault visibleExports) m.vars
+          adjustedDecls = map (injectExportDefault visibleExports) (m ^. #vars)
           myParentVars = Map.findWithDefault Map.empty inst parentVarsMap
           saved = Map.findWithDefault Map.empty inst savedValues
       case resolveVariablesWithSaved adjustedDecls cliOverrides saved envVars namespace context localConfig nsConfig ctxConfig globalConfig myParentVars of
         Right resolved -> do
-          let declaredNames = Set.fromList (map (.name) m.vars)
+          let declaredNames = Set.fromList (map (^. #name) (m ^. #vars))
               inherited =
                 Map.mapWithKey
                   makeInheritedResolved
@@ -197,18 +198,18 @@
           let optionalDecls =
                 [ d
                 | d <- adjustedDecls,
-                  not d.required,
-                  not (Map.member d.name resolvedWithInherited),
-                  any (\p -> p.var == d.name) m.prompts
+                  not (d ^. #required),
+                  not (Map.member (d ^. #name) resolvedWithInherited),
+                  any (\p -> p ^. #var == d ^. #name) (m ^. #prompts)
                 ]
           optionalPrompted <-
             if interactive && not (null optionalDecls)
               then do
-                let currentBindings = Map.map (.value) (Map.unions (Map.elems perModule))
-                    allBindings = Map.union (Map.map (.value) resolvedWithInherited) currentBindings
+                let currentBindings = Map.map (^. #value) (Map.unions (Map.elems perModule))
+                    allBindings = Map.union (Map.map (^. #value) resolvedWithInherited) currentBindings
                 putText ""
                 putText "Optional configuration:"
-                runPrompts m.prompts optionalDecls allBindings
+                runPrompts (m ^. #prompts) optionalDecls allBindings
               else pure Map.empty
           let fullResolved = resolvedWithInherited `Map.union` optionalPrompted
               myExports = exportedVars m fullResolved
@@ -226,16 +227,16 @@
               if not interactive || null missing
                 then pure (Left errs)
                 else do
-                  let currentBindings = Map.map (.value) (Map.unions (Map.elems perModule))
-                      missingDecls = [d | d <- adjustedDecls, d.name `elem` map getMissingName missing]
-                  prompted <- runPrompts m.prompts missingDecls currentBindings
+                  let currentBindings = Map.map (^. #value) (Map.unions (Map.elems perModule))
+                      missingDecls = [d | d <- adjustedDecls, (d ^. #name) `elem` map getMissingName missing]
+                  prompted <- runPrompts (m ^. #prompts) missingDecls currentBindings
                   let stillMissing = [e | e <- missing, not (Map.member (getMissingName e) prompted)]
                   if not (null stillMissing)
                     then pure (Left stillMissing)
                     else do
                       let promptedOverrides =
                             Map.union cliOverrides $
-                              Map.map (varValueToText . (.value)) prompted
+                              Map.map (varValueToText . (^. #value)) prompted
                       case resolveVariablesWithSaved adjustedDecls promptedOverrides saved envVars namespace context localConfig nsConfig ctxConfig globalConfig myParentVars of
                         Left errs' -> pure (Left errs')
                         Right resolved -> do
@@ -247,7 +248,7 @@
                                         Nothing -> rv
                                   )
                                   resolved
-                              declaredNames = Set.fromList (map (.name) m.vars)
+                              declaredNames = Set.fromList (map (^. #name) (m ^. #vars))
                               inherited =
                                 Map.mapWithKey
                                   makeInheritedResolved
@@ -256,18 +257,18 @@
                           let optionalDecls' =
                                 [ d
                                 | d <- adjustedDecls,
-                                  not d.required,
-                                  not (Map.member d.name resolvedWithInherited'),
-                                  any (\p -> p.var == d.name) m.prompts
+                                  not (d ^. #required),
+                                  not (Map.member (d ^. #name) resolvedWithInherited'),
+                                  any (\p -> p ^. #var == d ^. #name) (m ^. #prompts)
                                 ]
                           optionalPrompted' <-
                             if not (null optionalDecls')
                               then do
-                                let cb = Map.map (.value) (Map.unions (Map.elems perModule))
-                                    ab = Map.union (Map.map (.value) resolvedWithInherited') cb
+                                let cb = Map.map (^. #value) (Map.unions (Map.elems perModule))
+                                    ab = Map.union (Map.map (^. #value) resolvedWithInherited') cb
                                 putText ""
                                 putText "Optional configuration:"
-                                runPrompts m.prompts optionalDecls' ab
+                                runPrompts (m ^. #prompts) optionalDecls' ab
                               else pure Map.empty
                           let fullResolved = resolvedWithInherited' `Map.union` optionalPrompted'
                               myExports = exportedVars m fullResolved
@@ -281,7 +282,7 @@
 -- | Collect the exports visible along a module's dependency edges.
 --
 -- Each dependency edge is resolved to the exact child instance
--- @(depModule, depVars)@, not just the module name, so that two
+-- @(module_, vars)@, not just the module name, so that two
 -- sibling instances of the same module contribute their own
 -- per-instance exports without interference.
 gatherEdgeExports ::
@@ -291,8 +292,8 @@
 gatherEdgeExports m allExports =
   Map.unions
     [ Map.findWithDefault Map.empty childInst allExports
-    | dep <- m.dependencies,
-      let childInst = mkInstance dep.depModule (parentVarsFromDep dep)
+    | dep <- m ^. #dependencies,
+      let childInst = mkInstance (dep ^. #module_) (parentVarsFromDep dep)
     ]
 
 -- | Extract the variable name from a MissingRequiredVar error.
@@ -320,14 +321,14 @@
 exportedVars :: Module -> Map VarName ResolvedVar -> Map VarName VarValue
 exportedVars m resolved =
   Map.fromList
-    [ (exportName e, rv.value)
-    | e <- m.exports,
-      Just rv <- [Map.lookup e.var resolved]
+    [ (exportName e, rv ^. #value)
+    | e <- m ^. #exports,
+      Just rv <- [Map.lookup (e ^. #var) resolved]
     ]
   where
-    exportName e = case e.alias of
+    exportName e = case e ^. #alias of
       Just a -> a
-      Nothing -> e.var
+      Nothing -> (e ^. #var)
 
 -- Internal helpers
 
@@ -365,14 +366,14 @@
 loadTransitive searchPaths loaded (inst : rest)
   | Map.member inst loaded = loadTransitive searchPaths loaded rest
   | otherwise = do
-      result <- loadModuleWithDir searchPaths inst.instanceModule
+      result <- loadModuleWithDir searchPaths (inst ^. #module_)
       case result of
         Left err -> pure (Left err)
         Right (m, dir) -> do
           let loaded' = Map.insert inst (m, dir) loaded
               newInstances =
-                [ mkInstance dep.depModule (parentVarsFromDep dep)
-                | dep <- m.dependencies
+                [ mkInstance (dep ^. #module_) (parentVarsFromDep dep)
+                | dep <- m ^. #dependencies
                 ]
           loadTransitive searchPaths loaded' (rest ++ newInstances)
 
@@ -381,8 +382,8 @@
 -- the module author's default while still being overridable by CLI/env.
 injectExportDefault :: Map VarName VarValue -> VarDecl -> VarDecl
 injectExportDefault exports decl =
-  case Map.lookup decl.name exports of
-    Just val -> decl {default_ = Just val}
+  case Map.lookup (decl ^. #name) exports of
+    Just val -> (decl & #default_ ?~ val)
     Nothing -> decl
 
 -- | Create a ResolvedVar for an inherited (non-declared) export variable.
@@ -415,7 +416,7 @@
 -- Returns a map keyed by 'ModuleInstance' — not by bare 'ModuleName' —
 -- so two sibling invocations of the same child, each supplied with
 -- different bindings by different parents, carry their own edge
--- decorations independently. A child edge's @depVars@ uniquely
+-- decorations independently. A child edge's @vars@ uniquely
 -- identifies the target instance, so no merging of overlapping
 -- bindings is required: each @(ModuleInstance, edgeVars)@ pair is
 -- distinct by construction.
@@ -424,11 +425,11 @@
   Map ModuleInstance (Map VarName (Text, ModuleName))
 collectParentVars modules =
   Map.fromList
-    [ (childInst, Map.map (,m.name) dep.depVars)
+    [ (childInst, Map.map (,m ^. #name) (dep ^. #vars))
     | (_, m, _) <- modules,
-      dep <- m.dependencies,
-      not (Map.null dep.depVars),
-      let childInst = mkInstance dep.depModule (parentVarsFromDep dep)
+      dep <- m ^. #dependencies,
+      not (Map.null (dep ^. #vars)),
+      let childInst = mkInstance (dep ^. #module_) (parentVarsFromDep dep)
     ]
 
 -- | Remove duplicates from a list while preserving order, using a key function.
diff --git a/src/Seihou/Core/AgentPrompt.hs b/src/Seihou/Core/AgentPrompt.hs
--- a/src/Seihou/Core/AgentPrompt.hs
+++ b/src/Seihou/Core/AgentPrompt.hs
@@ -10,9 +10,11 @@
     checkAgentPromptFiles,
     checkAgentPromptTags,
     checkAgentPromptAllowedTools,
+    checkAgentPromptLaunch,
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Set qualified as Set
 import Data.Text qualified as T
 import Numeric.Natural (Natural)
@@ -38,21 +40,22 @@
           <> checkAgentPromptGuidance p
           <> checkAgentPromptTags p
           <> checkAgentPromptAllowedTools p
+          <> checkAgentPromptLaunch p
       allErrs = pureErrs <> fileErrs
   pure $
     if null allErrs
       then Right p
-      else Left (ValidationError p.name allErrs)
+      else Left (ValidationError (p ^. #name) allErrs)
 
 checkAgentPromptNameFormat :: AgentPrompt -> [Text]
 checkAgentPromptNameFormat p =
-  let n = p.name.unModuleName
+  let n = (p ^. #name . #unModuleName)
    in if T.null n || not (isValidModuleName n)
         then ["prompt name must match [a-z][a-z0-9-]*, got: " <> n]
         else []
 
 checkAgentPromptVersionPresent :: AgentPrompt -> [Text]
-checkAgentPromptVersionPresent p = case p.version of
+checkAgentPromptVersionPresent p = case p ^. #version of
   Nothing -> []
   Just v
     | T.null (T.strip v) -> ["prompt version, if specified, must not be empty"]
@@ -60,30 +63,30 @@
 
 checkAgentPromptBodyNonEmpty :: AgentPrompt -> [Text]
 checkAgentPromptBodyNonEmpty p
-  | T.null (T.strip p.prompt) = ["prompt body must not be empty"]
+  | T.null (T.strip (p ^. #prompt)) = ["prompt body must not be empty"]
   | otherwise = []
 
 checkAgentPromptUniqueVars :: AgentPrompt -> [Text]
 checkAgentPromptUniqueVars p =
-  let names = map (\d -> d.name.unVarName) p.vars
+  let names = map (\d -> d ^. #name . #unVarName) (p ^. #vars)
    in map (\n -> "duplicate variable name: " <> n) (findDupes Set.empty Set.empty names)
 
 checkAgentPromptPromptRefs :: AgentPrompt -> [Text]
 checkAgentPromptPromptRefs p =
-  let varNames = Set.fromList (map (.name) p.vars)
+  let varNames = Set.fromList (map (^. #name) (p ^. #vars))
    in concatMap
         ( \prompt ->
-            if Set.member prompt.var varNames
+            if Set.member (prompt ^. #var) varNames
               then []
-              else ["prompt references undeclared variable: " <> prompt.var.unVarName]
+              else ["prompt references undeclared variable: " <> prompt ^. #var . #unVarName]
         )
-        p.prompts
+        (p ^. #prompts)
 
 checkAgentPromptCommandVars :: AgentPrompt -> [Text]
 checkAgentPromptCommandVars p =
-  duplicateCommandVars <> concatMap checkCommandVar p.commandVars
+  duplicateCommandVars <> concatMap checkCommandVar (p ^. #commandVars)
   where
-    commandNames = map (\cv -> cv.name.unVarName) p.commandVars
+    commandNames = map (\cv -> cv ^. #name . #unVarName) (p ^. #commandVars)
 
     duplicateCommandVars =
       map
@@ -91,14 +94,14 @@
         (findDupes Set.empty Set.empty commandNames)
 
     checkCommandVar cv =
-      checkName cv <> checkRun cv <> checkWorkDir cv.workDir <> checkMaxBytes cv.maxBytes
+      checkName cv <> checkRun cv <> checkWorkDir (cv ^. #workDir) <> checkMaxBytes (cv ^. #maxBytes)
 
     checkName cv
-      | T.null (T.strip cv.name.unVarName) = ["command variable name must not be empty"]
+      | T.null (T.strip (cv ^. #name . #unVarName)) = ["command variable name must not be empty"]
       | otherwise = []
 
     checkRun cv
-      | T.null (T.strip cv.run) = ["command variable '" <> cv.name.unVarName <> "' run must not be empty"]
+      | T.null (T.strip (cv ^. #run)) = ["command variable '" <> cv ^. #name . #unVarName <> "' run must not be empty"]
       | otherwise = []
 
     checkWorkDir Nothing = []
@@ -118,26 +121,26 @@
 
 checkAgentPromptGuidance :: AgentPrompt -> [Text]
 checkAgentPromptGuidance p =
-  concatMap checkGuidance p.guidance
+  concatMap checkGuidance (p ^. #guidance)
   where
-    knownVars = Set.fromList (map (.name) p.vars <> map (.name) p.commandVars)
+    knownVars = Set.fromList (map (^. #name) (p ^. #vars) <> map (^. #name) (p ^. #commandVars))
 
     checkGuidance g =
       checkTitle g <> checkBody g <> checkConditionRefs g
 
     checkTitle g
-      | T.null (T.strip g.title) = ["guidance title must not be empty"]
+      | T.null (T.strip (g ^. #title)) = ["guidance title must not be empty"]
       | otherwise = []
 
     checkBody g
-      | T.null (T.strip g.body) = ["guidance body must not be empty"]
+      | T.null (T.strip (g ^. #body)) = ["guidance body must not be empty"]
       | otherwise = []
 
     checkConditionRefs g =
-      case g.condition of
+      case g ^. #condition of
         Nothing -> []
         Just cond ->
-          [ "guidance '" <> g.title <> "' references undeclared variable: " <> ref.unVarName
+          [ "guidance '" <> g ^. #title <> "' references undeclared variable: " <> ref ^. #unVarName
           | (ref, _) <- exprRefs cond,
             not (Set.member ref knownVars)
           ]
@@ -147,30 +150,48 @@
   concat
     <$> mapM
       ( \pf -> do
-          let path = baseDir </> "files" </> pf.src
+          let path = baseDir </> "files" </> (pf ^. #src)
           exists <- doesFileExist path
           pure $
             if exists
               then []
-              else ["prompt file not found: " <> T.pack pf.src]
+              else ["prompt file not found: " <> T.pack (pf ^. #src)]
       )
-      p.files
+      (p ^. #files)
 
 checkAgentPromptTags :: AgentPrompt -> [Text]
 checkAgentPromptTags p =
   [ "tag must not be empty"
-  | t <- p.tags,
+  | t <- p ^. #tags,
     T.null (T.strip t)
   ]
 
 checkAgentPromptAllowedTools :: AgentPrompt -> [Text]
-checkAgentPromptAllowedTools p = case p.allowedTools of
+checkAgentPromptAllowedTools p = case p ^. #allowedTools of
   Nothing -> []
   Just xs ->
     [ "allowedTools entry must not be empty"
     | t <- xs,
       T.null (T.strip t)
     ]
+
+-- | Every field the @launch@ record does set must be non-blank. The declared
+-- values themselves (which provider, which effort) are parsed by the CLI
+-- layer, which owns those vocabularies; core only rejects blanks.
+checkAgentPromptLaunch :: AgentPrompt -> [Text]
+checkAgentPromptLaunch p = case p ^. #launch of
+  Nothing -> []
+  Just l ->
+    blankErr "provider" (l ^. #provider)
+      <> blankErr "model" (l ^. #model)
+      <> blankErr "effort" (l ^. #effort)
+      <> blankErr "mode" (l ^. #mode)
+  where
+    blankErr key value =
+      [ "launch." <> key <> ", if specified, must not be empty"
+      | Just v <- [value],
+        T.null (T.strip v)
+      ]
 
 findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
 findDupes _ _ [] = []
diff --git a/src/Seihou/Core/Application.hs b/src/Seihou/Core/Application.hs
--- a/src/Seihou/Core/Application.hs
+++ b/src/Seihou/Core/Application.hs
@@ -6,6 +6,8 @@
   )
 where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict (Map)
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
@@ -21,38 +23,42 @@
 -- update replaces the same application record.
 mkApplicationId :: AppliedTarget -> [ModuleName] -> ApplicationId
 mkApplicationId target additional =
-  ApplicationId (hashContent canonical).unSHA256
+  ApplicationId ((hashContent canonical) ^. #unSHA256)
   where
     (kind, targetName) = case target of
-      AppliedModuleTarget name -> ("module", name.unModuleName)
-      AppliedRecipeTarget name -> ("recipe", name.unRecipeName)
+      AppliedModuleTarget name -> ("module", name ^. #unModuleName)
+      AppliedRecipeTarget name -> ("recipe", name ^. #unRecipeName)
     canonical =
       T.intercalate
         "\n"
         ( [ "target-kind=" <> kind,
             "target-name=" <> targetName
           ]
-            ++ map ("additional=" <>) (map (.unModuleName) additional)
+            ++ map ("additional=" <>) (map (^. #unModuleName) additional)
         )
 
 -- | Capture a composition using the already-resolved, instance-scoped
 -- values from the generation pipeline.
+--
+-- The target and each module instance are identified by the portable
+-- 'ArtifactOrigin' the manifest records, never by the directory they happened
+-- to be loaded from on this machine.
 buildAppliedComposition ::
   AppliedTarget ->
-  FilePath ->
+  ArtifactOrigin ->
   Maybe Text ->
   [ModuleName] ->
   Maybe Text ->
   Maybe Text ->
-  [(ModuleInstance, Module, FilePath)] ->
+  [(ModuleInstance, Module, ArtifactOrigin)] ->
   Map ModuleInstance (Map VarName ResolvedVar) ->
   UTCTime ->
   AppliedComposition
-buildAppliedComposition target targetSource targetVersion additional namespace context modulesInOrder resolved now =
+buildAppliedComposition target targetOrigin targetVersion additional namespace context modulesInOrder resolved now =
   AppliedComposition
     { applicationId = mkApplicationId target additional,
       target = target,
-      targetSource = targetSource,
+      targetOrigin = targetOrigin,
       targetVersion = targetVersion,
       additionalModules = additional,
       namespace = namespace,
@@ -62,24 +68,24 @@
       appliedAt = now
     }
   where
-    buildInstance (inst, modul, source) =
+    buildInstance (inst, modul, origin) =
       AppliedInstanceState
-        { name = inst.instanceModule,
-          parentVars = inst.instanceParentVars,
-          source = source,
-          moduleVersion = modul.version,
-          resolvedVars = Map.map (varValueToText . (.value)) (Map.findWithDefault Map.empty inst resolved)
+        { name = inst ^. #module_,
+          parentVars = inst ^. #parentVars,
+          origin = origin,
+          moduleVersion = modul ^. #version,
+          resolvedVars = Map.map (varValueToText . (^. #value)) (Map.findWithDefault Map.empty inst resolved)
         }
 
 -- | Replace an existing application in place, or append a newly-applied one.
 replaceAppliedComposition :: AppliedComposition -> [AppliedComposition] -> [AppliedComposition]
 replaceAppliedComposition replacement existing
-  | any ((== replacement.applicationId) . (.applicationId)) existing =
+  | any ((== replacement ^. #applicationId) . (^. #applicationId)) existing =
       map replaceMatching existing
   | otherwise = existing ++ [replacement]
   where
     replaceMatching current
-      | current.applicationId == replacement.applicationId = replacement
+      | current ^. #applicationId == (replacement ^. #applicationId) = replacement
       | otherwise = current
 
 -- | Attribute the current file result to an application while retaining
@@ -89,10 +95,9 @@
 attachApplication :: ApplicationId -> Maybe FileRecord -> FileRecord -> FileRecord
 attachApplication applicationId previous current =
   current
-    { applicationIds = Set.insert applicationId (Set.union current.applicationIds priorApplications)
-    }
+    & #applicationIds .~ Set.insert applicationId (Set.union (current ^. #applicationIds) priorApplications)
   where
-    priorApplications = maybe Set.empty (.applicationIds) previous
+    priorApplications = maybe Set.empty (^. #applicationIds) previous
 
 varValueToText :: VarValue -> Text
 varValueToText (VText value) = value
diff --git a/src/Seihou/Core/ArtifactOriginDetect.hs b/src/Seihou/Core/ArtifactOriginDetect.hs
new file mode 100644
--- /dev/null
+++ b/src/Seihou/Core/ArtifactOriginDetect.hs
@@ -0,0 +1,115 @@
+-- | Turn an absolute artifact directory into a portable 'ArtifactOrigin'.
+--
+-- Module discovery hands every caller an absolute directory, because
+-- @Seihou.Core.Module.defaultSearchPaths@ is built from
+-- 'System.Directory.getCurrentDirectory' and
+-- 'System.Directory.getXdgDirectory'. Absolute paths must never reach
+-- @.seihou\/manifest.json@, which is checked into version control and read
+-- on other developers' machines, so every manifest write site funnels its
+-- directory through 'detectArtifactOrigin' first.
+--
+-- The read side of @.seihou-origin.json@ lives here rather than in
+-- @seihou-cli@ because this module needs it and @seihou-core@ cannot depend
+-- on @seihou-cli-internal@. @Seihou.CLI.InstallShared@ re-exports it, so
+-- existing importers are unaffected; the write side ('OriginMeta',
+-- @installModuleDir@) stays in the CLI.
+module Seihou.Core.ArtifactOriginDetect
+  ( detectArtifactOrigin,
+    OriginInfo (..),
+    readOriginInfo,
+  )
+where
+
+import Control.Exception (IOException, try)
+import Data.Aeson (FromJSON (..), withObject, (.:), (.:?))
+import Data.Aeson qualified as Aeson
+import Data.ByteString.Lazy qualified as LBS
+import Data.Generics.Labels ()
+import Data.Text qualified as T
+import Seihou.Core.Types (ArtifactOrigin (..))
+import Seihou.Prelude
+import System.Directory (canonicalizePath, doesFileExist)
+import System.FilePath (makeRelative, pathSeparator, takeFileName)
+
+-- | Read side of @.seihou-origin.json@. Tolerates files written by older
+-- 'seihou install' runs that may have been missing optional fields.
+data OriginInfo = OriginInfo
+  { sourceUrl :: !Text,
+    repoName :: !(Maybe Text),
+    version :: !(Maybe Text)
+  }
+  deriving stock (Eq, Generic, Show)
+
+instance FromJSON OriginInfo where
+  parseJSON = withObject "OriginInfo" $ \v ->
+    OriginInfo <$> v .: "sourceUrl" <*> v .:? "repoName" <*> v .:? "version"
+
+-- | Read and parse @.seihou-origin.json@ at the given installed-module
+-- directory. Returns 'Nothing' if the file is absent or unparseable.
+readOriginInfo :: FilePath -> IO (Maybe OriginInfo)
+readOriginInfo installedDir = do
+  let path = installedDir </> ".seihou-origin.json"
+  exists <- doesFileExist path
+  if not exists
+    then pure Nothing
+    else do
+      bs <- LBS.readFile path
+      pure (Aeson.decode bs)
+
+-- | Classify an absolute artifact directory into a portable origin.
+--
+-- @projectRoot@ is the absolute path of the project being generated into
+-- (the directory holding @.seihou@). @artifactDir@ is the absolute
+-- directory that holds the artifact's @module.dhall@, @recipe.dhall@,
+-- @blueprint.dhall@, or @prompt.dhall@.
+--
+-- Classification, in order:
+--
+--   1. If @artifactDir@ is inside @projectRoot@, the result is a
+--      'ProjectOrigin' holding the path relative to @projectRoot@ with
+--      forward slashes.
+--   2. Otherwise, if @artifactDir@ contains a readable
+--      @.seihou-origin.json@ with a @sourceUrl@, the result is a
+--      'RemoteOrigin' carrying that URL, the directory's base name, and
+--      the recorded @repoName@.
+--   3. Otherwise the result is a 'LocalOrigin' holding the directory's
+--      base name.
+detectArtifactOrigin :: FilePath -> FilePath -> IO ArtifactOrigin
+detectArtifactOrigin projectRoot artifactDir = do
+  root <- canonicalizeOr projectRoot
+  dir <- canonicalizeOr artifactDir
+  case insideProject root dir of
+    Just relative -> pure (ProjectOrigin relative)
+    Nothing -> do
+      originInfo <- readOriginInfo dir
+      let name = T.pack (takeFileName dir)
+      pure $ case originInfo of
+        Just info -> RemoteOrigin (info ^. #sourceUrl) name (info ^. #repoName)
+        Nothing -> LocalOrigin name
+
+-- | 'canonicalizePath' throws when an intermediate component does not
+-- exist, which happens in tests and for artifacts that were removed between
+-- discovery and manifest write. Fall back to the raw path in that case.
+canonicalizeOr :: FilePath -> IO FilePath
+canonicalizeOr path = do
+  result <- try @IOException (canonicalizePath path)
+  pure (either (const path) id result)
+
+-- | The artifact directory's path relative to the project root, when it is
+-- genuinely inside it.
+--
+-- 'makeRelative' returns its second argument unchanged when the two paths
+-- share no prefix, and returns @"."@ when they are the same directory, so
+-- both cases have to be rejected explicitly. A leading @".."@ cannot appear
+-- (GHC's 'makeRelative' never produces one) but is rejected anyway so a
+-- future implementation change cannot smuggle an escaping path into the
+-- manifest.
+insideProject :: FilePath -> FilePath -> Maybe FilePath
+insideProject root dir
+  | relative == dir = Nothing
+  | relative == "." = Nothing
+  | take 2 relative == ".." = Nothing
+  | otherwise = Just (map toForwardSlash relative)
+  where
+    relative = makeRelative root dir
+    toForwardSlash c = if c == pathSeparator then '/' else c
diff --git a/src/Seihou/Core/ArtifactRef.hs b/src/Seihou/Core/ArtifactRef.hs
new file mode 100644
--- /dev/null
+++ b/src/Seihou/Core/ArtifactRef.hs
@@ -0,0 +1,143 @@
+-- | Turn an artifact origin recorded in the manifest into a directory on
+-- this machine.
+--
+-- @.seihou\/manifest.json@ is checked into version control and records no
+-- absolute path (see
+-- docs\/adr\/0001-manifest-is-a-checked-in-machine-independent-artifact.md),
+-- so every command that needs an artifact's bytes has to ask this question
+-- first. This module is the single place that answers it, and the single
+-- place that phrases the answer when it is "not here".
+module Seihou.Core.ArtifactRef
+  ( ArtifactRefError (..),
+    resolveArtifactOrigin,
+    renderArtifactRefError,
+  )
+where
+
+import Data.Generics.Labels ()
+import Data.Text qualified as T
+import Seihou.Core.Types (ArtifactOrigin (..))
+import Seihou.Prelude
+import System.Directory (doesFileExist)
+import System.FilePath (joinPath)
+
+-- | Why an origin recorded in the manifest could not be turned into a
+-- directory on this machine.
+data ArtifactRefError
+  = -- | Nothing named by the origin exists in any search path. Carries the
+    -- origin and the exact directories that were probed, in order.
+    ArtifactNotFoundLocally !ArtifactOrigin ![FilePath]
+  | -- | A 'ProjectOrigin' pointed at a path inside the project that does
+    -- not exist. Carries the origin and the absolute path that was tried.
+    ProjectArtifactMissing !ArtifactOrigin !FilePath
+  deriving stock (Eq, Show, Generic)
+
+-- | Turn a recorded origin into the absolute directory on this machine
+-- that holds the artifact's definition file.
+--
+-- @projectRoot@ is the absolute directory containing @.seihou@.
+-- @searchPaths@ is normally 'Seihou.Core.Module.defaultSearchPaths'; it is
+-- a parameter so tests can supply temporary directories.
+-- @definitionFile@ is the file that must be present for a directory to
+-- count as the artifact — @"module.dhall"@ for modules,
+-- @"recipe.dhall"@ for recipes, @"blueprint.dhall"@ for blueprints.
+--
+-- A 'ProjectOrigin' resolves against the project root and nowhere else. If
+-- the recorded directory is absent the repository is incomplete, and
+-- quietly substituting a globally installed artifact of the same name would
+-- be exactly the invisible substitution the portable manifest exists to
+-- prevent.
+--
+-- A 'RemoteOrigin' or 'LocalOrigin' resolves by name through @searchPaths@
+-- in the ordinary discovery order, so a developer who deliberately shadows
+-- an installed module with a project-local copy keeps that shadowing.
+-- Whether what was found actually matches the recorded origin is a separate
+-- question, answered by
+-- docs\/plans\/78-refuse-accidental-module-downgrades-and-origin-mismatches.md.
+resolveArtifactOrigin ::
+  FilePath ->
+  [FilePath] ->
+  FilePath ->
+  ArtifactOrigin ->
+  IO (Either ArtifactRefError FilePath)
+resolveArtifactOrigin projectRoot searchPaths definitionFile origin = case origin of
+  ProjectOrigin relative -> do
+    let candidate = projectRoot </> fromPortablePath relative
+    present <- hasDefinition candidate
+    pure $
+      if present
+        then Right candidate
+        else Left (ProjectArtifactMissing origin candidate)
+  RemoteOrigin _ artifact _ -> searchByName artifact
+  LocalOrigin artifact -> searchByName artifact
+  where
+    searchByName artifact = do
+      let candidates = [dir </> T.unpack artifact | dir <- searchPaths]
+      found <- firstPresent candidates
+      pure (maybe (Left (ArtifactNotFoundLocally origin candidates)) Right found)
+
+    firstPresent [] = pure Nothing
+    firstPresent (candidate : rest) = do
+      present <- hasDefinition candidate
+      if present then pure (Just candidate) else firstPresent rest
+
+    hasDefinition directory = doesFileExist (directory </> definitionFile)
+
+-- | The manifest stores project-relative paths with forward slashes so a
+-- manifest written on Windows matches one written on POSIX. Turn one back
+-- into a native path.
+fromPortablePath :: FilePath -> FilePath
+fromPortablePath = joinPath . filter (not . null) . splitOnSlash
+  where
+    splitOnSlash path = case break (== '/') path of
+      (segment, []) -> [segment]
+      (segment, _ : rest) -> segment : splitOnSlash rest
+
+-- | Render a resolution failure as the multi-line message the user sees.
+--
+-- Callers prepend their own one-line context ("cannot plan a migration",
+-- "cannot regenerate"); the body below is identical everywhere so a reader
+-- who has seen it once recognises it.
+renderArtifactRefError :: ArtifactRefError -> Text
+renderArtifactRefError (ProjectArtifactMissing origin candidate) =
+  T.intercalate
+    "\n"
+    [ "Artifact '" <> artifactOriginLabel origin <> "' is recorded in .seihou/manifest.json",
+      "as living inside this project, but the directory is missing.",
+      "",
+      "  Expected at: " <> T.pack candidate,
+      "",
+      "This directory should be committed alongside the manifest. Restore it",
+      "from version control, or re-run the module that creates it."
+    ]
+renderArtifactRefError (ArtifactNotFoundLocally origin candidates) =
+  T.intercalate "\n" (header <> [""] <> recordedOrigin <> searched <> [""] <> remedy)
+  where
+    label = artifactOriginLabel origin
+
+    header =
+      [ "Artifact '" <> label <> "' is recorded in .seihou/manifest.json but is not",
+        "installed on this machine."
+      ]
+
+    recordedOrigin = case origin of
+      RemoteOrigin url _ _ -> ["  Recorded origin: " <> url, ""]
+      _ -> []
+
+    searched = "  Searched:" : ["    " <> T.pack candidate | candidate <- candidates]
+
+    remedy = case origin of
+      RemoteOrigin url _ _ ->
+        [ "  Install it with:",
+          "    seihou install " <> url
+        ]
+      _ ->
+        [ "  This artifact has no recorded upstream, so seihou cannot fetch it.",
+          "  Place a copy in one of the directories above."
+        ]
+
+-- | The name to show a user for an origin.
+artifactOriginLabel :: ArtifactOrigin -> Text
+artifactOriginLabel (RemoteOrigin _ artifact _) = artifact
+artifactOriginLabel (LocalOrigin artifact) = artifact
+artifactOriginLabel (ProjectOrigin relative) = T.pack relative
diff --git a/src/Seihou/Core/Blueprint.hs b/src/Seihou/Core/Blueprint.hs
--- a/src/Seihou/Core/Blueprint.hs
+++ b/src/Seihou/Core/Blueprint.hs
@@ -12,9 +12,11 @@
     checkBlueprintTags,
     checkBlueprintAllowedTools,
     checkBlueprintMigrations,
+    checkBlueprintLaunch,
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -43,6 +45,11 @@
 --   7. Every @files@ entry exists at @baseDir/files/SRC@.
 --   8. Every tag is non-empty.
 --   9. Every @allowedTools@ entry, when set, is non-empty.
+--  10. Every migration is a forward dotted-numeric edge with a non-empty
+--      prompt, and each starting version occurs at most once.
+--  11. Every field the @launch@ record does set is non-blank. The values
+--      themselves are parsed by the CLI, which owns the provider and effort
+--      vocabularies.
 validateBlueprint :: FilePath -> Blueprint -> IO (Either ModuleLoadError Blueprint)
 validateBlueprint baseDir b = do
   searchPaths <- defaultSearchPaths
@@ -69,23 +76,24 @@
           <> checkBlueprintTags b
           <> checkBlueprintAllowedTools b
           <> checkBlueprintMigrations b
+          <> checkBlueprintLaunch b
       allErrs = pureErrs <> fileErrs <> baseErrs
   pure $
     if null allErrs
       then Right b
-      else Left (ValidationError b.name allErrs)
+      else Left (ValidationError (b ^. #name) allErrs)
 
 -- Rule 1: blueprint name must match [a-z][a-z0-9-]*
 checkBlueprintNameFormat :: Blueprint -> [Text]
 checkBlueprintNameFormat b =
-  let n = b.name.unModuleName
+  let n = (b ^. #name . #unModuleName)
    in if T.null n || not (isValidModuleName n)
         then ["blueprint name must match [a-z][a-z0-9-]*, got: " <> n]
         else []
 
 -- Rule 2: if a version is given it must not be empty
 checkBlueprintVersionPresent :: Blueprint -> [Text]
-checkBlueprintVersionPresent b = case b.version of
+checkBlueprintVersionPresent b = case b ^. #version of
   Nothing -> []
   Just v
     | T.null (T.strip v) -> ["blueprint version, if specified, must not be empty"]
@@ -94,13 +102,13 @@
 -- Rule 3: prompt body must not be empty after trimming
 checkBlueprintPromptNonEmpty :: Blueprint -> [Text]
 checkBlueprintPromptNonEmpty b
-  | T.null (T.strip b.prompt) = ["blueprint prompt must not be empty"]
+  | T.null (T.strip (b ^. #prompt)) = ["blueprint prompt must not be empty"]
   | otherwise = []
 
 -- Rule 4: declared variable names must be unique
 checkBlueprintUniqueVars :: Blueprint -> [Text]
 checkBlueprintUniqueVars b =
-  let names = map (\d -> d.name.unVarName) b.vars
+  let names = map (\d -> d ^. #name . #unVarName) (b ^. #vars)
    in map (\n -> "duplicate variable name: " <> n) (findDupes Set.empty Set.empty names)
 
 findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
@@ -112,14 +120,14 @@
 -- Rule 5: every prompt references a declared variable
 checkBlueprintPromptRefs :: Blueprint -> [Text]
 checkBlueprintPromptRefs b =
-  let varNames = Set.fromList (map (.name) b.vars)
+  let varNames = Set.fromList (map (^. #name) (b ^. #vars))
    in concatMap
         ( \p ->
-            if Set.member p.var varNames
+            if Set.member (p ^. #var) varNames
               then []
-              else ["prompt references undeclared variable: " <> p.var.unVarName]
+              else ["prompt references undeclared variable: " <> p ^. #var . #unVarName]
         )
-        b.prompts
+        (b ^. #prompts)
 
 -- Rule 6: base modules must be well-formed and resolve to a module or
 -- recipe (not another blueprint). The check uses the same default
@@ -132,25 +140,25 @@
 
 checkBlueprintBaseModulesWith :: [FilePath] -> Blueprint -> IO [Text]
 checkBlueprintBaseModulesWith searchPaths b =
-  concat <$> mapM (checkOne searchPaths) b.baseModules
+  concat <$> mapM (checkOne searchPaths) (b ^. #baseModules)
   where
     checkOne :: [FilePath] -> Dependency -> IO [Text]
     checkOne paths dep = do
-      let n = dep.depModule.unModuleName
+      let n = (dep ^. #module_ . #unModuleName)
           nameErrs =
             [ "invalid baseModule name: " <> n
             | not (isValidModuleName n)
             ]
           bindingErrs =
             [ "baseModule '" <> n <> "' has invalid var binding name: " <> vn
-            | (VarName vn) <- Map.keys dep.depVars,
+            | (VarName vn) <- Map.keys (dep ^. #vars),
               not (isValidVarBindingName vn)
             ]
       resolveErrs <-
         if not (isValidModuleName n)
           then pure []
           else do
-            result <- discoverRunnable paths dep.depModule
+            result <- discoverRunnable paths (dep ^. #module_)
             pure $ case result of
               Right (RunnableModule _ _) -> []
               Right (RunnableRecipe _ _) -> []
@@ -181,26 +189,26 @@
   concat
     <$> mapM
       ( \bf -> do
-          let p = baseDir </> "files" </> bf.src
+          let p = baseDir </> "files" </> (bf ^. #src)
           exists <- doesFileExist p
           pure $
             if exists
               then []
-              else ["blueprint file not found: " <> T.pack bf.src]
+              else ["blueprint file not found: " <> T.pack (bf ^. #src)]
       )
-      b.files
+      (b ^. #files)
 
 -- Rule 8: tags must not be empty strings
 checkBlueprintTags :: Blueprint -> [Text]
 checkBlueprintTags b =
   [ "tag must not be empty"
-  | t <- b.tags,
+  | t <- b ^. #tags,
     T.null (T.strip t)
   ]
 
 -- Rule 9: @allowedTools@, when set, must contain only non-empty entries
 checkBlueprintAllowedTools :: Blueprint -> [Text]
-checkBlueprintAllowedTools b = case b.allowedTools of
+checkBlueprintAllowedTools b = case b ^. #allowedTools of
   Nothing -> []
   Just xs ->
     [ "allowedTools entry must not be empty"
@@ -212,23 +220,23 @@
 -- non-empty prompt, and each starting version occurs at most once.
 checkBlueprintMigrations :: Blueprint -> [Text]
 checkBlueprintMigrations b =
-  concatMap checkOne b.migrations <> duplicateErrors
+  concatMap checkOne (b ^. #migrations) <> duplicateErrors
   where
     checkOne :: BlueprintMigration -> [Text]
     checkOne migration =
       promptErrors migration
-        <> versionErrors "from" migration.from
-        <> versionErrors "to" migration.to
+        <> versionErrors "from" (migration ^. #from)
+        <> versionErrors "to" (migration ^. #to)
         <> orderErrors migration
 
     promptErrors :: BlueprintMigration -> [Text]
     promptErrors migration =
       [ "blueprint migration "
-          <> migration.from
+          <> migration ^. #from
           <> " -> "
-          <> migration.to
+          <> migration ^. #to
           <> " prompt must not be empty"
-      | T.null (T.strip migration.prompt)
+      | T.null (T.strip (migration ^. #prompt))
       ]
 
     versionErrors label versionText = case parseVersion versionText of
@@ -236,17 +244,35 @@
       Just _ -> []
 
     orderErrors :: BlueprintMigration -> [Text]
-    orderErrors migration = case (parseVersion migration.from, parseVersion migration.to) of
+    orderErrors migration = case (parseVersion (migration ^. #from), parseVersion (migration ^. #to)) of
       (Just fromVersion, Just toVersion)
         | fromVersion >= toVersion ->
             [ "blueprint migration must advance versions: "
-                <> migration.from
+                <> migration ^. #from
                 <> " -> "
-                <> migration.to
+                <> migration ^. #to
             ]
       _ -> []
 
     duplicateErrors =
       map
         ("duplicate blueprint migration from version: " <>)
-        (findDupes Set.empty Set.empty (map (.from) b.migrations))
+        (findDupes Set.empty Set.empty (map (^. #from) (b ^. #migrations)))
+
+-- Rule 11: every field the @launch@ record does set must be non-blank. The
+-- declared values themselves (which provider, which effort) are parsed by the
+-- CLI layer, which owns those vocabularies; core only rejects blanks.
+checkBlueprintLaunch :: Blueprint -> [Text]
+checkBlueprintLaunch b = case b ^. #launch of
+  Nothing -> []
+  Just l ->
+    blankErr "provider" (l ^. #provider)
+      <> blankErr "model" (l ^. #model)
+      <> blankErr "effort" (l ^. #effort)
+      <> blankErr "mode" (l ^. #mode)
+  where
+    blankErr key value =
+      [ "launch." <> key <> ", if specified, must not be empty"
+      | Just v <- [value],
+        T.null (T.strip v)
+      ]
diff --git a/src/Seihou/Core/CommandFingerprint.hs b/src/Seihou/Core/CommandFingerprint.hs
--- a/src/Seihou/Core/CommandFingerprint.hs
+++ b/src/Seihou/Core/CommandFingerprint.hs
@@ -3,6 +3,8 @@
   )
 where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.Types
 import Seihou.Manifest.Hash (hashContent)
@@ -15,7 +17,7 @@
   Just . CommandFingerprint . hashContent $
     T.intercalate
       "\n"
-      [ "module=" <> moduleName.unModuleName,
+      [ "module=" <> moduleName ^. #unModuleName,
         "command=" <> command,
         "work-dir=" <> T.pack (normalise (maybe "." id workDir)),
         "occurrence=" <> T.pack (show occurrence)
diff --git a/src/Seihou/Core/CommandVar.hs b/src/Seihou/Core/CommandVar.hs
--- a/src/Seihou/Core/CommandVar.hs
+++ b/src/Seihou/Core/CommandVar.hs
@@ -5,6 +5,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Core.Expr (evalExpr)
@@ -24,18 +25,18 @@
     conditionBindings = resolvedValues existing <> bindings
 
     shouldRun cv =
-      not (Map.member cv.name existing)
-        && maybe True (evalExpr conditionBindings) cv.condition
+      not (Map.member (cv ^. #name) existing)
+        && maybe True (evalExpr conditionBindings) (cv ^. #condition)
 
 -- | Return the matching declaration for a command variable, or synthesize a
 -- text declaration for prompt-only dynamic context such as @git.branch@.
 commandVarDecl :: [VarDecl] -> CommandVar -> VarDecl
 commandVarDecl decls cv =
-  case filter (\decl -> decl.name == cv.name) decls of
+  case filter (\decl -> decl ^. #name == cv ^. #name) decls of
     decl : _ -> decl
     [] ->
       VarDecl
-        { name = cv.name,
+        { name = cv ^. #name,
           type_ = VTText,
           default_ = Nothing,
           description = Nothing,
@@ -63,15 +64,15 @@
   where
     go errs resolved _bindings [] = pure (reverse errs, resolved)
     go errs resolved bindings (cv : rest)
-      | Map.member cv.name resolved = go errs resolved bindings rest
-      | maybe False (not . evalExpr bindings) cv.condition = go errs resolved bindings rest
+      | Map.member (cv ^. #name) resolved = go errs resolved bindings rest
+      | maybe False (not . evalExpr bindings) (cv ^. #condition) = go errs resolved bindings rest
       | otherwise = do
           result <- resolveOne bindings cv
           case result of
             Left err -> go (err : errs) resolved bindings rest
             Right rv ->
-              let resolved' = Map.insert cv.name rv resolved
-                  bindings' = Map.insert cv.name rv.value bindings
+              let resolved' = Map.insert (cv ^. #name) rv resolved
+                  bindings' = Map.insert (cv ^. #name) (rv ^. #value) bindings
                in go errs resolved' bindings' rest
 
     resolveOne _bindings cv = do
@@ -79,13 +80,13 @@
       case validateWorkDir cv of
         Left err -> pure (Left err)
         Right workDir -> do
-          (exitCode, stdoutText, stderrText) <- runProcess "sh" ["-c", cv.run] workDir
+          (exitCode, stdoutText, stderrText) <- runProcess "sh" ["-c", cv ^. #run] workDir
           pure $ case exitCode of
             ExitSuccess -> coerceCommandOutput decl cv stdoutText
             ExitFailure code ->
               Left $
                 ValidationFailed
-                  cv.name
+                  (cv ^. #name)
                   ( "command failed with exit code "
                       <> T.pack (show code)
                       <> ": "
@@ -96,31 +97,31 @@
     validateWorkDir cv@CommandVar {workDir = Nothing} = Right Nothing
     validateWorkDir cv@CommandVar {workDir = Just wd} =
       case validateProjectRelativePath wd of
-        Left err -> Left (ValidationFailed cv.name ("command variable workDir " <> err))
+        Left err -> Left (ValidationFailed (cv ^. #name) ("command variable workDir " <> err))
         Right _ -> Right (Just (T.unpack wd))
 
 coerceCommandOutput :: VarDecl -> CommandVar -> Text -> Either VarError ResolvedVar
 coerceCommandOutput decl cv stdoutText = do
   let output =
-        if cv.trim
+        if cv ^. #trim
           then T.strip stdoutText
           else stdoutText
-  case cv.maxBytes of
+  case cv ^. #maxBytes of
     Just n
       | fromIntegral (T.length output) > n ->
-          Left (ValidationFailed cv.name ("command output exceeds maxBytes " <> T.pack (show n)))
+          Left (ValidationFailed (cv ^. #name) ("command output exceeds maxBytes " <> T.pack (show n)))
     _ -> do
-      value <- coerceValue decl.name decl.type_ output
+      value <- coerceValue (decl ^. #name) (decl ^. #type_) output
       validateVarValue decl value
       Right
         ResolvedVar
           { value = value,
-            source = FromCommand cv.run,
+            source = FromCommand (cv ^. #run),
             decl = decl
           }
 
 resolvedValues :: Map VarName ResolvedVar -> Map VarName VarValue
-resolvedValues = Map.map (.value)
+resolvedValues = Map.map (^. #value)
 
 summarizeDiagnostic :: Text -> Text
 summarizeDiagnostic t =
diff --git a/src/Seihou/Core/Migration.hs b/src/Seihou/Core/Migration.hs
--- a/src/Seihou/Core/Migration.hs
+++ b/src/Seihou/Core/Migration.hs
@@ -13,8 +13,8 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.List (sortOn)
-import GHC.Generics (Generic)
 import Seihou.Core.Version (Version, parseVersion)
 import Seihou.Prelude
 
@@ -34,19 +34,19 @@
 --     by this op; if the command moves files, the migration author is
 --     responsible for following it with explicit move/delete ops.
 data MigrationOp
-  = MoveFile {src :: FilePath, dest :: FilePath}
-  | MoveDir {src :: FilePath, dest :: FilePath}
-  | DeleteFile {path :: FilePath}
-  | DeleteDir {path :: FilePath}
-  | RunCommand {run :: Text, workDir :: Maybe FilePath}
+  = MoveFile {src :: !FilePath, dest :: !FilePath}
+  | MoveDir {src :: !FilePath, dest :: !FilePath}
+  | DeleteFile {path :: !FilePath}
+  | DeleteDir {path :: !FilePath}
+  | RunCommand {run :: !Text, workDir :: !(Maybe FilePath)}
   deriving stock (Eq, Show, Generic)
 
 -- | A migration that moves a project from module version @from@ to module
 -- version @to@. The 'ops' list is applied in declaration order.
 data Migration = Migration
-  { from :: Text,
-    to :: Text,
-    ops :: [MigrationOp]
+  { from :: !Text,
+    to :: !Text,
+    ops :: ![MigrationOp]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -54,9 +54,9 @@
 -- version strings use the same dotted-numeric format as module migrations,
 -- while 'prompt' describes only the changes needed for this edge.
 data BlueprintMigration = BlueprintMigration
-  { from :: Text,
-    to :: Text,
-    prompt :: Text
+  { from :: !Text,
+    to :: !Text,
+    prompt :: !Text
   }
   deriving stock (Eq, Show, Generic)
 
@@ -85,20 +85,20 @@
 --
 -- The plan carries the module name for rendering, the start and end
 -- versions of the user-visible "X → Y" header, and the ordered list of
--- migrations that will run. A plan with @planSteps == []@ means the
--- manifest will advance from @planFrom@ to @planTo@ without running
+-- migrations that will run. A plan with @steps == []@ means the
+-- manifest will advance from @from@ to @to@ without running
 -- any migration ops (a pure version bump).
 data MigrationPlan = MigrationPlan
-  { planModule :: Text,
+  { module_ :: !Text,
     -- | Installed (manifest) version at the start.
-    planFrom :: Version,
+    from :: !Version,
     -- | Target version. The manifest will land here after the plan
     -- runs, regardless of whether any of the declared migrations
-    -- bridge every gap inside @[planFrom, planTo]@.
-    planTo :: Version,
+    -- bridge every gap inside @[from, to]@.
+    to :: !Version,
     -- | The migrations that actually apply, in ascending @from@
     -- order. May be empty.
-    planSteps :: [Migration]
+    steps :: ![Migration]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -106,10 +106,10 @@
 -- window. A non-trivial window may have no selected steps when the author
 -- declared no agent intervention for that range.
 data BlueprintMigrationPlan = BlueprintMigrationPlan
-  { blueprintPlanName :: Text,
-    blueprintPlanFrom :: Version,
-    blueprintPlanTo :: Version,
-    blueprintPlanSteps :: [BlueprintMigration]
+  { name :: !Text,
+    from :: !Version,
+    to :: !Version,
+    steps :: ![BlueprintMigration]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -168,14 +168,14 @@
     ( fmap
         ( \steps ->
             MigrationPlan
-              { planModule = modName,
-                planFrom = installed,
-                planTo = target,
-                planSteps = steps
+              { module_ = modName,
+                from = installed,
+                to = target,
+                steps = steps
               }
         )
     )
-    (planMigrationWindow (.from) (.to) migrations installed target)
+    (planMigrationWindow (^. #from) (^. #to) migrations installed target)
 
 -- | Compute the ordered agent-guided migrations for a blueprint and version
 -- window. Selection and errors deliberately match 'planMigrationChain'.
@@ -190,14 +190,14 @@
     ( fmap
         ( \steps ->
             BlueprintMigrationPlan
-              { blueprintPlanName = blueprintName,
-                blueprintPlanFrom = current,
-                blueprintPlanTo = target,
-                blueprintPlanSteps = steps
+              { name = blueprintName,
+                from = current,
+                to = target,
+                steps = steps
               }
         )
     )
-    (planMigrationWindow (.from) (.to) migrations current target)
+    (planMigrationWindow (^. #from) (^. #to) migrations current target)
 
 -- | Shared gap-tolerant version-window planner. Keeping parsing, duplicate
 -- detection, ordering, overlap handling, and overshoot handling here prevents
diff --git a/src/Seihou/Core/Module.hs b/src/Seihou/Core/Module.hs
--- a/src/Seihou/Core/Module.hs
+++ b/src/Seihou/Core/Module.hs
@@ -31,10 +31,10 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
-import GHC.Generics (Generic)
 import Seihou.Core.Path (validateProjectRelativePath)
 import Seihou.Core.Types
 import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalModuleFromFile, evalRecipeFromFile)
@@ -47,7 +47,7 @@
 discoverModule :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError FilePath)
 discoverModule searchPaths name = go searchPaths
   where
-    nameStr = T.unpack name.unModuleName
+    nameStr = T.unpack (name ^. #unModuleName)
     go [] = pure $ Left (ModuleNotFound name searchPaths)
     go (dir : rest) = do
       let candidate = dir </> nameStr
@@ -70,7 +70,7 @@
 discoverRunnable :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError Runnable)
 discoverRunnable searchPaths name = go searchPaths
   where
-    nameStr = T.unpack name.unModuleName
+    nameStr = T.unpack (name ^. #unModuleName)
     go [] = pure $ Left (ModuleNotFound name searchPaths)
     go (dir : rest) = do
       let candidate = dir </> nameStr
@@ -119,7 +119,7 @@
 discoverBlueprint :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError FilePath)
 discoverBlueprint searchPaths name = go searchPaths
   where
-    nameStr = T.unpack name.unModuleName
+    nameStr = T.unpack (name ^. #unModuleName)
     go [] = pure $ Left (ModuleNotFound name searchPaths)
     go (dir : rest) = do
       let candidate = dir </> nameStr
@@ -135,7 +135,7 @@
 discoverAgentPrompt :: [FilePath] -> ModuleName -> IO (Either ModuleLoadError FilePath)
 discoverAgentPrompt searchPaths name = go searchPaths
   where
-    nameStr = T.unpack name.unModuleName
+    nameStr = T.unpack (name ^. #unModuleName)
     go [] = pure $ Left (ModuleNotFound name searchPaths)
     go (dir : rest) = do
       let candidate = dir </> nameStr
@@ -181,12 +181,12 @@
   pure $
     if null allErrors
       then Right m
-      else Left (ValidationError m.name allErrors)
+      else Left (ValidationError (m ^. #name) allErrors)
 
 -- Rule 1: Module name must be non-empty and match [a-z][a-z0-9-]*
 checkNameFormat :: Module -> [Text]
 checkNameFormat m =
-  let n = m.name.unModuleName
+  let n = (m ^. #name . #unModuleName)
    in if T.null n || not (isValidModuleName n)
         then ["module name must match [a-z][a-z0-9-]*, got: " <> n]
         else []
@@ -200,7 +200,7 @@
 
 -- Rule 1b: Module must declare a version
 checkVersionPresent :: Module -> [Text]
-checkVersionPresent m = case m.version of
+checkVersionPresent m = case m ^. #version of
   Nothing -> ["module must declare a version"]
   Just v
     | T.null (T.strip v) -> ["module must declare a version"]
@@ -209,7 +209,7 @@
 -- Rule 2: All variable names must be unique
 checkUniqueVars :: Module -> [Text]
 checkUniqueVars m =
-  let names = map (\d -> d.name.unVarName) m.vars
+  let names = map (\d -> d ^. #name . #unVarName) (m ^. #vars)
    in map (\n -> "duplicate variable name: " <> n) (findDupes Set.empty Set.empty names)
 
 findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
@@ -221,14 +221,14 @@
 -- Rule 3: Every prompt must reference a declared variable
 checkPromptRefs :: Module -> [Text]
 checkPromptRefs m =
-  let varNames = Set.fromList (map (.name) m.vars)
+  let varNames = Set.fromList (map (^. #name) (m ^. #vars))
    in concatMap
         ( \p ->
-            if Set.member p.var varNames
+            if Set.member (p ^. #var) varNames
               then []
-              else ["prompt references undeclared variable: " <> p.var.unVarName]
+              else ["prompt references undeclared variable: " <> p ^. #var . #unVarName]
         )
-        m.prompts
+        (m ^. #prompts)
 
 -- Rule 4: Every step source file must exist in the module's files/ directory
 checkFileExistence :: FilePath -> Module -> IO [Text]
@@ -236,38 +236,38 @@
   concat
     <$> mapM
       ( \s -> do
-          let p = baseDir </> "files" </> s.src
+          let p = baseDir </> "files" </> (s ^. #src)
           exists <- doesFileExist p
           pure $
             if exists
               then []
-              else ["step source file not found: " <> T.pack s.src]
+              else ["step source file not found: " <> T.pack (s ^. #src)]
       )
-      m.steps
+      (m ^. #steps)
 
 -- Rule 5: Every export must reference a declared variable
 checkExportRefs :: Module -> [Text]
 checkExportRefs m =
-  let varNames = Set.fromList (map (.name) m.vars)
+  let varNames = Set.fromList (map (^. #name) (m ^. #vars))
    in concatMap
         ( \e ->
-            if Set.member e.var varNames
+            if Set.member (e ^. #var) varNames
               then []
-              else ["export references undeclared variable: " <> e.var.unVarName]
+              else ["export references undeclared variable: " <> e ^. #var . #unVarName]
         )
-        m.exports
+        (m ^. #exports)
 
 -- Rule 6: Every dependency name must be well-formed
 checkDependencyNames :: Module -> [Text]
 checkDependencyNames m =
   concatMap
     ( \dep ->
-        let n = dep.depModule.unModuleName
+        let n = (dep ^. #module_ . #unModuleName)
          in if isValidModuleName n
               then []
               else ["invalid dependency name: " <> n]
     )
-    m.dependencies
+    (m ^. #dependencies)
 
 -- Rule 6b: Dependency var binding names must be non-empty
 checkDependencyVarBindings :: Module -> [Text]
@@ -277,42 +277,42 @@
         concatMap
           ( \(VarName vn) ->
               if T.null vn
-                then ["dependency '" <> dep.depModule.unModuleName <> "' has empty var binding name"]
+                then ["dependency '" <> dep ^. #module_ . #unModuleName <> "' has empty var binding name"]
                 else []
           )
-          (Map.keys dep.depVars)
+          (Map.keys (dep ^. #vars))
     )
-    m.dependencies
+    (m ^. #dependencies)
 
 -- Rule 7: Every step destination must be a safe relative path
 checkSafeDestinations :: Module -> [Text]
-checkSafeDestinations m = concatMap checkDest m.steps
+checkSafeDestinations m = concatMap checkDest (m ^. #steps)
   where
     checkDest s =
-      case validateProjectRelativePath s.dest of
+      case validateProjectRelativePath (s ^. #dest) of
         Left err -> ["step destination " <> err]
         Right _ -> []
 
 -- Rule 8: Variables referenced in step dest placeholders must be declared
 checkDestVarRefs :: Module -> [Text]
 checkDestVarRefs m =
-  let varNames = Set.fromList (map (\d -> d.name.unVarName) m.vars)
-   in concatMap (checkStep varNames) m.steps
+  let varNames = Set.fromList (map (\d -> d ^. #name . #unVarName) (m ^. #vars))
+   in concatMap (checkStep varNames) (m ^. #steps)
   where
     checkStep varNames s =
       [ "step destination references undeclared variable: " <> ref
-      | ref <- extractPlaceholders s.dest,
+      | ref <- extractPlaceholders (s ^. #dest),
         not (Set.member ref varNames)
       ]
 
 -- Rule 9: Command text must be non-empty and workDir must be safe
 checkCommandSafety :: Module -> [Text]
-checkCommandSafety m = concatMap checkCmd m.commands
+checkCommandSafety m = concatMap checkCmd (m ^. #commands)
   where
-    checkCmd c = checkEmptyRun c <> checkWorkDir c.workDir
+    checkCmd c = checkEmptyRun c <> checkWorkDir (c ^. #workDir)
 
     checkEmptyRun c
-      | T.null (T.strip c.run) = ["command text must not be empty"]
+      | T.null (T.strip (c ^. #run)) = ["command text must not be empty"]
       | otherwise = []
 
     checkWorkDir Nothing = []
@@ -352,11 +352,11 @@
 
 -- | A module discovered during enumeration, with its load result and source.
 data DiscoveredModule = DiscoveredModule
-  { discoveredResult :: Either ModuleLoadError Module,
-    discoveredSource :: ModuleSource,
-    discoveredDir :: FilePath
+  { result :: !(Either ModuleLoadError Module),
+    source :: !ModuleSource,
+    dir :: !FilePath
   }
-  deriving stock (Show)
+  deriving stock (Generic, Show)
 
 -- | Enumerate all modules across the given search paths.
 -- The search paths must be in the same order as 'defaultSearchPaths':
@@ -399,7 +399,7 @@
       result <- case decoded of
         Left err -> pure (Left err)
         Right m -> validateModule moduleDir m
-      pure DiscoveredModule {discoveredResult = result, discoveredSource = src, discoveredDir = moduleDir}
+      pure DiscoveredModule {result = result, source = src, dir = moduleDir}
 
 -- | Whether a discovered item is a module, recipe, blueprint, or prompt.
 data RunnableKind = KindModule | KindRecipe | KindBlueprint | KindPrompt
@@ -407,15 +407,15 @@
 
 -- | A runnable discovered during enumeration, with its load result, kind, and source.
 data DiscoveredRunnable = DiscoveredRunnable
-  { drName :: Text,
-    drDescription :: Maybe Text,
-    drKind :: RunnableKind,
-    drSource :: ModuleSource,
-    drDir :: FilePath,
-    drIsError :: Bool,
-    drError :: Maybe Text
+  { name :: !Text,
+    description :: !(Maybe Text),
+    kind :: !RunnableKind,
+    source :: !ModuleSource,
+    dir :: !FilePath,
+    isError :: !Bool,
+    error :: !(Maybe Text)
   }
-  deriving stock (Show)
+  deriving stock (Generic, Show)
 
 -- | Enumerate all modules, recipes, blueprints, and prompts across the given search paths.
 -- Returns a unified list of discovered items, each tagged with its kind.
@@ -453,23 +453,23 @@
             [ case decoded of
                 Left err ->
                   DiscoveredRunnable
-                    { drName = T.pack entry,
-                      drDescription = Nothing,
-                      drKind = KindModule,
-                      drSource = src,
-                      drDir = entryDir,
-                      drIsError = True,
-                      drError = Just (briefLoadError err)
+                    { name = T.pack entry,
+                      description = Nothing,
+                      kind = KindModule,
+                      source = src,
+                      dir = entryDir,
+                      isError = True,
+                      error = Just (briefLoadError err)
                     }
                 Right m ->
                   DiscoveredRunnable
-                    { drName = m.name.unModuleName,
-                      drDescription = m.description,
-                      drKind = KindModule,
-                      drSource = src,
-                      drDir = entryDir,
-                      drIsError = False,
-                      drError = Nothing
+                    { name = m ^. #name . #unModuleName,
+                      description = m ^. #description,
+                      kind = KindModule,
+                      source = src,
+                      dir = entryDir,
+                      isError = False,
+                      error = Nothing
                     }
             ]
         else
@@ -480,23 +480,23 @@
                 [ case decoded of
                     Left err ->
                       DiscoveredRunnable
-                        { drName = T.pack entry,
-                          drDescription = Nothing,
-                          drKind = KindRecipe,
-                          drSource = src,
-                          drDir = entryDir,
-                          drIsError = True,
-                          drError = Just (briefLoadError err)
+                        { name = T.pack entry,
+                          description = Nothing,
+                          kind = KindRecipe,
+                          source = src,
+                          dir = entryDir,
+                          isError = True,
+                          error = Just (briefLoadError err)
                         }
                     Right r ->
                       DiscoveredRunnable
-                        { drName = r.name.unRecipeName,
-                          drDescription = r.description,
-                          drKind = KindRecipe,
-                          drSource = src,
-                          drDir = entryDir,
-                          drIsError = False,
-                          drError = Nothing
+                        { name = r ^. #name . #unRecipeName,
+                          description = r ^. #description,
+                          kind = KindRecipe,
+                          source = src,
+                          dir = entryDir,
+                          isError = False,
+                          error = Nothing
                         }
                 ]
             else
@@ -507,23 +507,23 @@
                     [ case decoded of
                         Left err ->
                           DiscoveredRunnable
-                            { drName = T.pack entry,
-                              drDescription = Nothing,
-                              drKind = KindBlueprint,
-                              drSource = src,
-                              drDir = entryDir,
-                              drIsError = True,
-                              drError = Just (briefLoadError err)
+                            { name = T.pack entry,
+                              description = Nothing,
+                              kind = KindBlueprint,
+                              source = src,
+                              dir = entryDir,
+                              isError = True,
+                              error = Just (briefLoadError err)
                             }
                         Right b ->
                           DiscoveredRunnable
-                            { drName = b.name.unModuleName,
-                              drDescription = b.description,
-                              drKind = KindBlueprint,
-                              drSource = src,
-                              drDir = entryDir,
-                              drIsError = False,
-                              drError = Nothing
+                            { name = b ^. #name . #unModuleName,
+                              description = b ^. #description,
+                              kind = KindBlueprint,
+                              source = src,
+                              dir = entryDir,
+                              isError = False,
+                              error = Nothing
                             }
                     ]
                 else
@@ -534,23 +534,23 @@
                         [ case decoded of
                             Left err ->
                               DiscoveredRunnable
-                                { drName = T.pack entry,
-                                  drDescription = Nothing,
-                                  drKind = KindPrompt,
-                                  drSource = src,
-                                  drDir = entryDir,
-                                  drIsError = True,
-                                  drError = Just (briefLoadError err)
+                                { name = T.pack entry,
+                                  description = Nothing,
+                                  kind = KindPrompt,
+                                  source = src,
+                                  dir = entryDir,
+                                  isError = True,
+                                  error = Just (briefLoadError err)
                                 }
                             Right p ->
                               DiscoveredRunnable
-                                { drName = p.name.unModuleName,
-                                  drDescription = p.description,
-                                  drKind = KindPrompt,
-                                  drSource = src,
-                                  drDir = entryDir,
-                                  drIsError = False,
-                                  drError = Nothing
+                                { name = p ^. #name . #unModuleName,
+                                  description = p ^. #description,
+                                  kind = KindPrompt,
+                                  source = src,
+                                  dir = entryDir,
+                                  isError = False,
+                                  error = Nothing
                                 }
                         ]
                     else pure []
diff --git a/src/Seihou/Core/Recipe.hs b/src/Seihou/Core/Recipe.hs
--- a/src/Seihou/Core/Recipe.hs
+++ b/src/Seihou/Core/Recipe.hs
@@ -3,6 +3,8 @@
   )
 where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -33,7 +35,7 @@
 -- Rule 1: Recipe name must match [a-z][a-z0-9-]*
 checkRecipeNameFormat :: Recipe -> [Text]
 checkRecipeNameFormat recipe =
-  let n = recipe.name.unRecipeName
+  let n = (recipe ^. #name . #unRecipeName)
    in if T.null n || not (isValidModuleName n)
         then ["recipe name must match [a-z][a-z0-9-]*, got: " <> n]
         else []
@@ -41,13 +43,13 @@
 -- Rule 2: At least one module must be listed
 checkNonEmptyModules :: Recipe -> [Text]
 checkNonEmptyModules recipe
-  | null recipe.modules = ["recipe must list at least one module"]
+  | null (recipe ^. #modules) = ["recipe must list at least one module"]
   | otherwise = []
 
 -- Rule 3: No duplicate module names
 checkNoDuplicateModules :: Recipe -> [Text]
 checkNoDuplicateModules recipe =
-  let names = map (.depModule.unModuleName) recipe.modules
+  let names = map (^. #module_ . #unModuleName) (recipe ^. #modules)
    in map (\n -> "duplicate module in recipe: " <> n) (findDupes Set.empty Set.empty names)
 
 findDupes :: Set.Set Text -> Set.Set Text -> [Text] -> [Text]
@@ -59,16 +61,16 @@
 -- Rule 4: Variable binding names must match [a-z][a-z0-9.-]*
 checkVarBindingNames :: Recipe -> [Text]
 checkVarBindingNames recipe =
-  concatMap checkDep recipe.modules
+  concatMap checkDep (recipe ^. #modules)
   where
     checkDep dep =
       concatMap
         ( \(VarName vn) ->
             if isValidVarBindingName vn
               then []
-              else ["invalid var binding name '" <> vn <> "' in module '" <> dep.depModule.unModuleName <> "'"]
+              else ["invalid var binding name '" <> vn <> "' in module '" <> dep ^. #module_ . #unModuleName <> "'"]
         )
-        (Map.keys dep.depVars)
+        (Map.keys (dep ^. #vars))
 
     isValidVarBindingName :: Text -> Bool
     isValidVarBindingName t = case T.uncons t of
diff --git a/src/Seihou/Core/Registry.hs b/src/Seihou/Core/Registry.hs
--- a/src/Seihou/Core/Registry.hs
+++ b/src/Seihou/Core/Registry.hs
@@ -19,31 +19,31 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Text qualified as T
-import GHC.Generics (Generic)
 import Seihou.Core.Types (ModuleLoadError, ModuleName (..))
 import Seihou.Prelude
 import System.Directory (doesFileExist)
 
 -- | A single module listing within a registry.
 data RegistryEntry = RegistryEntry
-  { name :: ModuleName,
-    version :: Maybe Text,
-    path :: FilePath,
-    description :: Maybe Text,
-    tags :: [Text]
+  { name :: !ModuleName,
+    version :: !(Maybe Text),
+    path :: !FilePath,
+    description :: !(Maybe Text),
+    tags :: ![Text]
   }
   deriving stock (Eq, Show, Generic)
 
 -- | Registry metadata for a multi-module repository.
 -- Declared in @seihou-registry.dhall@ at the repo root.
 data Registry = Registry
-  { repoName :: Text,
-    repoDescription :: Maybe Text,
-    modules :: [RegistryEntry],
-    recipes :: [RegistryEntry],
-    blueprints :: [RegistryEntry],
-    prompts :: [RegistryEntry]
+  { repoName :: !Text,
+    repoDescription :: !(Maybe Text),
+    modules :: ![RegistryEntry],
+    recipes :: ![RegistryEntry],
+    blueprints :: ![RegistryEntry],
+    prompts :: ![RegistryEntry]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -114,20 +114,20 @@
 -- and no name collisions between modules, recipes, blueprints, and prompts.
 validateRegistry :: FilePath -> Registry -> IO [Text]
 validateRegistry repoRoot reg = do
-  modErrs <- concat <$> mapM (validateModuleEntry repoRoot) reg.modules
-  recErrs <- concat <$> mapM (validateRecipeEntry repoRoot) reg.recipes
-  bpErrs <- concat <$> mapM (validateBlueprintEntry repoRoot) reg.blueprints
-  promptErrs <- concat <$> mapM (validatePromptEntry repoRoot) reg.prompts
-  let collisionErrs = checkNameCollisions reg.modules reg.recipes reg.blueprints reg.prompts
+  modErrs <- concat <$> mapM (validateModuleEntry repoRoot) (reg ^. #modules)
+  recErrs <- concat <$> mapM (validateRecipeEntry repoRoot) (reg ^. #recipes)
+  bpErrs <- concat <$> mapM (validateBlueprintEntry repoRoot) (reg ^. #blueprints)
+  promptErrs <- concat <$> mapM (validatePromptEntry repoRoot) (reg ^. #prompts)
+  let collisionErrs = checkNameCollisions (reg ^. #modules) (reg ^. #recipes) (reg ^. #blueprints) (reg ^. #prompts)
   pure (modErrs <> recErrs <> bpErrs <> promptErrs <> collisionErrs)
 
 validateModuleEntry :: FilePath -> RegistryEntry -> IO [Text]
 validateModuleEntry repoRoot entry = do
-  let nameText = entry.name.unModuleName
+  let nameText = (entry ^. #name . #unModuleName)
       nameErrors = checkName nameText
-      pathText = T.pack entry.path
+      pathText = T.pack (entry ^. #path)
       pathErrors = checkPath pathText
-  let moduleDhall = repoRoot </> entry.path </> "module.dhall"
+  let moduleDhall = repoRoot </> entry ^. #path </> "module.dhall"
   fileExists <- doesFileExist moduleDhall
   let fileErrors =
         if fileExists
@@ -146,11 +146,11 @@
 
 validateRecipeEntry :: FilePath -> RegistryEntry -> IO [Text]
 validateRecipeEntry repoRoot entry = do
-  let nameText = entry.name.unModuleName
+  let nameText = (entry ^. #name . #unModuleName)
       nameErrors = checkRecipeName nameText
-      pathText = T.pack entry.path
+      pathText = T.pack (entry ^. #path)
       pathErrors = checkRecipePath pathText
-  let recipeDhall = repoRoot </> entry.path </> "recipe.dhall"
+  let recipeDhall = repoRoot </> entry ^. #path </> "recipe.dhall"
   fileExists <- doesFileExist recipeDhall
   let fileErrors =
         if fileExists
@@ -169,11 +169,11 @@
 
 validateBlueprintEntry :: FilePath -> RegistryEntry -> IO [Text]
 validateBlueprintEntry repoRoot entry = do
-  let nameText = entry.name.unModuleName
+  let nameText = (entry ^. #name . #unModuleName)
       nameErrors = checkBlueprintName nameText
-      pathText = T.pack entry.path
+      pathText = T.pack (entry ^. #path)
       pathErrors = checkBlueprintPath pathText
-  let blueprintDhall = repoRoot </> entry.path </> "blueprint.dhall"
+  let blueprintDhall = repoRoot </> entry ^. #path </> "blueprint.dhall"
   fileExists <- doesFileExist blueprintDhall
   let fileErrors =
         if fileExists
@@ -192,11 +192,11 @@
 
 validatePromptEntry :: FilePath -> RegistryEntry -> IO [Text]
 validatePromptEntry repoRoot entry = do
-  let nameText = entry.name.unModuleName
+  let nameText = (entry ^. #name . #unModuleName)
       nameErrors = checkPromptName nameText
-      pathText = T.pack entry.path
+      pathText = T.pack (entry ^. #path)
       pathErrors = checkPromptPath pathText
-  let promptDhall = repoRoot </> entry.path </> "prompt.dhall"
+  let promptDhall = repoRoot </> entry ^. #path </> "prompt.dhall"
   fileExists <- doesFileExist promptDhall
   let fileErrors =
         if fileExists
@@ -218,10 +218,10 @@
 -- appears in all four kinds produces six messages (one per pair).
 checkNameCollisions :: [RegistryEntry] -> [RegistryEntry] -> [RegistryEntry] -> [RegistryEntry] -> [Text]
 checkNameCollisions mods recs bps prompts =
-  let modNames = map (\e -> e.name.unModuleName) mods
-      recNames = map (\e -> e.name.unModuleName) recs
-      bpNames = map (\e -> e.name.unModuleName) bps
-      promptNames = map (\e -> e.name.unModuleName) prompts
+  let modNames = map (\e -> e ^. #name . #unModuleName) mods
+      recNames = map (\e -> e ^. #name . #unModuleName) recs
+      bpNames = map (\e -> e ^. #name . #unModuleName) bps
+      promptNames = map (\e -> e ^. #name . #unModuleName) prompts
       modRec = [n | n <- modNames, n `elem` recNames]
       modBp = [n | n <- modNames, n `elem` bpNames]
       modPrompt = [n | n <- modNames, n `elem` promptNames]
@@ -265,11 +265,11 @@
 
 -- | One row of a sync diff, preserving registry order.
 data SyncDiff = SyncDiff
-  { diffKind :: EntryKind,
-    diffName :: ModuleName,
-    diffOld :: Maybe Text,
-    diffNew :: Maybe Text,
-    diffStatus :: SyncStatus
+  { kind :: !EntryKind,
+    name :: !ModuleName,
+    old :: !(Maybe Text),
+    new :: !(Maybe Text),
+    status :: !SyncStatus
   }
   deriving stock (Eq, Show, Generic)
 
@@ -277,8 +277,8 @@
 -- and a 'Registry' with each entry's @version@ field updated to the on-disk
 -- value (except 'SyncOrphan' entries, which are preserved as-is).
 data SyncReport = SyncReport
-  { syncDiffs :: [SyncDiff],
-    syncUpdated :: Registry
+  { diffs :: ![SyncDiff],
+    updated :: !Registry
   }
   deriving stock (Eq, Show, Generic)
 
@@ -295,25 +295,28 @@
   SyncReport
 computeRegistrySync reg lookups =
   SyncReport
-    { syncDiffs = moduleDiffs <> recipeDiffs <> blueprintDiffs <> promptDiffs,
-      syncUpdated =
+    { diffs = moduleDiffs <> recipeDiffs <> blueprintDiffs <> promptDiffs,
+      updated =
         reg
-          { modules = zipWith applyDiff moduleDiffs reg.modules,
-            recipes = zipWith applyDiff recipeDiffs reg.recipes,
-            blueprints = zipWith applyDiff blueprintDiffs reg.blueprints,
-            prompts = zipWith applyDiff promptDiffs reg.prompts
-          }
+          & #modules
+          %~ zipWith applyDiff moduleDiffs
+          & #recipes
+          %~ zipWith applyDiff recipeDiffs
+          & #blueprints
+          %~ zipWith applyDiff blueprintDiffs
+          & #prompts
+          %~ zipWith applyDiff promptDiffs
     }
   where
-    moduleDiffs = map (classify ModuleEntry) reg.modules
-    recipeDiffs = map (classify RecipeEntry) reg.recipes
-    blueprintDiffs = map (classify BlueprintEntry) reg.blueprints
-    promptDiffs = map (classify PromptEntry) reg.prompts
+    moduleDiffs = map (classify ModuleEntry) (reg ^. #modules)
+    recipeDiffs = map (classify RecipeEntry) (reg ^. #recipes)
+    blueprintDiffs = map (classify BlueprintEntry) (reg ^. #blueprints)
+    promptDiffs = map (classify PromptEntry) (reg ^. #prompts)
 
     classify :: EntryKind -> RegistryEntry -> SyncDiff
     classify kind entry =
-      let onDisk = lookupOnDisk kind entry.name
-          status = case (entry.version, onDisk) of
+      let onDisk = lookupOnDisk kind (entry ^. #name)
+          status = case (entry ^. #version, onDisk) of
             (_, OnDiskMissing) -> SyncOrphan
             (Nothing, OnDiskValue Nothing) -> SyncInSync
             (Nothing, OnDiskValue (Just _)) -> SyncMissing
@@ -322,20 +325,20 @@
               | old == new -> SyncInSync
               | otherwise -> SyncStale new
           newVersion = case onDisk of
-            OnDiskMissing -> entry.version
+            OnDiskMissing -> (entry ^. #version)
             OnDiskValue v -> v
        in SyncDiff
-            { diffKind = kind,
-              diffName = entry.name,
-              diffOld = entry.version,
-              diffNew = newVersion,
-              diffStatus = status
+            { kind = kind,
+              name = entry ^. #name,
+              old = entry ^. #version,
+              new = newVersion,
+              status = status
             }
 
     applyDiff :: SyncDiff -> RegistryEntry -> RegistryEntry
-    applyDiff diff entry = case diff.diffStatus of
+    applyDiff diff entry = case diff ^. #status of
       SyncOrphan -> entry
-      _ -> entry {version = diff.diffNew}
+      _ -> entry & #version .~ diff ^. #new
 
     lookupOnDisk :: EntryKind -> ModuleName -> OnDiskVersion
     lookupOnDisk kind name =
@@ -352,28 +355,28 @@
 -- 'Nothing' if the entry is already in sync. Used by @seihou browse@ and
 -- @seihou install@ to surface stale registry versions without blocking.
 formatDriftWarning :: SyncDiff -> Maybe Text
-formatDriftWarning diff = case diff.diffStatus of
+formatDriftWarning diff = case diff ^. #status of
   SyncInSync -> Nothing
   SyncOrphan -> Nothing
   SyncMissing ->
     Just $
-      kindWord diff.diffKind
+      kindWord (diff ^. #kind)
         <> " '"
-        <> diff.diffName.unModuleName
+        <> diff ^. #name . #unModuleName
         <> "' registry version is missing; "
-        <> entryFile diff.diffKind
+        <> entryFile (diff ^. #kind)
         <> " declares "
-        <> renderVersion diff.diffNew
+        <> renderVersion (diff ^. #new)
         <> " — run `seihou registry sync-versions`"
   SyncStale newVer ->
     Just $
-      kindWord diff.diffKind
+      kindWord (diff ^. #kind)
         <> " '"
-        <> diff.diffName.unModuleName
+        <> diff ^. #name . #unModuleName
         <> "' registry version "
-        <> renderVersion diff.diffOld
+        <> renderVersion (diff ^. #old)
         <> " differs from "
-        <> entryFile diff.diffKind
+        <> entryFile (diff ^. #kind)
         <> " version "
         <> newVer
         <> " — run `seihou registry sync-versions`"
@@ -400,17 +403,17 @@
 -- | Whole-registry validation outcome, carrying every issue plus the
 -- entry counts used by the human-readable summary line.
 data RegistryValidationReport = RegistryValidationReport
-  { reportIssues :: [RegistryValidationIssue],
-    reportModuleCount :: Int,
-    reportRecipeCount :: Int,
-    reportBlueprintCount :: Int,
-    reportPromptCount :: Int
+  { issues :: ![RegistryValidationIssue],
+    moduleCount :: !Int,
+    recipeCount :: !Int,
+    blueprintCount :: !Int,
+    promptCount :: !Int
   }
   deriving stock (Eq, Show, Generic)
 
 -- | True iff the report has at least one issue.
 reportHasIssues :: RegistryValidationReport -> Bool
-reportHasIssues r = not (null r.reportIssues)
+reportHasIssues r = not (null (r ^. #issues))
 
 -- | Combine the existing structural checks with version classification.
 -- The third argument is the same shape 'computeRegistrySync' takes —
@@ -426,16 +429,16 @@
   let report = computeRegistrySync reg lookups
       versionIssues =
         [ VersionMismatch d
-        | d <- report.syncDiffs,
-          isVersionDrift d.diffStatus
+        | d <- report ^. #diffs,
+          isVersionDrift (d ^. #status)
         ]
   pure
     RegistryValidationReport
-      { reportIssues = map StructuralError structuralErrs <> versionIssues,
-        reportModuleCount = length reg.modules,
-        reportRecipeCount = length reg.recipes,
-        reportBlueprintCount = length reg.blueprints,
-        reportPromptCount = length reg.prompts
+      { issues = map StructuralError structuralErrs <> versionIssues,
+        moduleCount = length (reg ^. #modules),
+        recipeCount = length (reg ^. #recipes),
+        blueprintCount = length (reg ^. #blueprints),
+        promptCount = length (reg ^. #prompts)
       }
   where
     isVersionDrift SyncMissing = True
@@ -450,14 +453,14 @@
 formatValidationIssue :: RegistryValidationIssue -> Text
 formatValidationIssue (StructuralError msg) = msg
 formatValidationIssue (VersionMismatch diff) =
-  validationKindPrefix diff.diffKind
-    <> diff.diffName.unModuleName
+  validationKindPrefix (diff ^. #kind)
+    <> diff ^. #name . #unModuleName
     <> ": registry version "
-    <> validationRenderVersion diff.diffOld
+    <> validationRenderVersion (diff ^. #old)
     <> " does not match "
-    <> entryFile diff.diffKind
+    <> entryFile (diff ^. #kind)
     <> " version "
-    <> validationRenderVersion diff.diffNew
+    <> validationRenderVersion (diff ^. #new)
   where
     entryFile ModuleEntry = "module.dhall"
     entryFile RecipeEntry = "recipe.dhall"
@@ -480,16 +483,16 @@
 renderRegistryDhall :: Registry -> Text
 renderRegistryDhall reg =
   T.unlines
-    [ "{ repoName = " <> renderString reg.repoName,
-      ", repoDescription = " <> renderOptionalText reg.repoDescription,
+    [ "{ repoName = " <> renderString (reg ^. #repoName),
+      ", repoDescription = " <> renderOptionalText (reg ^. #repoDescription),
       ", modules =",
-      renderEntryList reg.modules,
+      renderEntryList (reg ^. #modules),
       ", recipes =",
-      renderEntryList reg.recipes,
+      renderEntryList (reg ^. #recipes),
       ", blueprints =",
-      renderEntryList reg.blueprints,
+      renderEntryList (reg ^. #blueprints),
       ", prompts =",
-      renderEntryList reg.prompts,
+      renderEntryList (reg ^. #prompts),
       "}"
     ]
 
@@ -505,11 +508,11 @@
 renderEntry isFirst entry =
   T.intercalate
     "\n"
-    [ "  " <> opener <> " { name = " <> renderString entry.name.unModuleName,
-      "    , version = " <> renderOptionalText entry.version,
-      "    , path = " <> renderString (T.pack entry.path),
-      "    , description = " <> renderOptionalText entry.description,
-      "    , tags = " <> renderTextList entry.tags,
+    [ "  " <> opener <> " { name = " <> renderString (entry ^. #name . #unModuleName),
+      "    , version = " <> renderOptionalText (entry ^. #version),
+      "    , path = " <> renderString (T.pack (entry ^. #path)),
+      "    , description = " <> renderOptionalText (entry ^. #description),
+      "    , tags = " <> renderTextList (entry ^. #tags),
       "    }"
     ]
   where
diff --git a/src/Seihou/Core/Scaffold.hs b/src/Seihou/Core/Scaffold.hs
--- a/src/Seihou/Core/Scaffold.hs
+++ b/src/Seihou/Core/Scaffold.hs
@@ -87,6 +87,10 @@
       "    , files = [] : List S.Blueprint.BlueprintFile.Type",
       "    , migrations = [] : List S.BlueprintMigration.Type",
       "    , tags = [] : List Text",
+      "      -- Optional: declare the agent this blueprint was written for.",
+      "      -- These override the invoking user's config files but lose to a",
+      "      -- --provider / --model / --effort flag or a SEIHOU_AGENT_* variable.",
+      "      -- , launch = Some S.Launch::{ effort = Some \"max\" }",
       "    }"
     ]
 
diff --git a/src/Seihou/Core/Status.hs b/src/Seihou/Core/Status.hs
--- a/src/Seihou/Core/Status.hs
+++ b/src/Seihou/Core/Status.hs
@@ -3,6 +3,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.List (sortOn)
 import Data.Map.Strict qualified as Map
 import Seihou.Core.Types
@@ -16,8 +17,8 @@
 -- 'TfsDeleted' if the file no longer exists on disk.
 computeTrackedFileStatuses :: (Filesystem :> es) => Manifest -> Eff es [TrackedFile]
 computeTrackedFileStatuses manifest = do
-  results <- mapM classifyFile (Map.toAscList manifest.files)
-  pure (sortOn (.path) results)
+  results <- mapM classifyFile (Map.toAscList (manifest ^. #files))
+  pure (sortOn (^. #path) results)
   where
     classifyFile :: (Filesystem :> es') => (FilePath, FileRecord) -> Eff es' TrackedFile
     classifyFile (path, record) = do
@@ -29,13 +30,13 @@
             content <- readFileText path
             let diskHash = hashContent content
             pure
-              ( if diskHash == record.hash
+              ( if diskHash == record ^. #hash
                   then TfsUnchanged
                   else TfsModified
               )
       pure
         TrackedFile
           { path = path,
-            moduleName = record.moduleName,
+            moduleName = record ^. #moduleName,
             status = status
           }
diff --git a/src/Seihou/Core/Types.hs b/src/Seihou/Core/Types.hs
--- a/src/Seihou/Core/Types.hs
+++ b/src/Seihou/Core/Types.hs
@@ -28,7 +28,7 @@
     Blueprint (..),
     CommandVar (..),
     PromptGuidance (..),
-    AgentPromptLaunch (..),
+    AgentLaunch (..),
     AgentPrompt (..),
     Runnable (..),
     recipeNameToModuleName,
@@ -36,6 +36,7 @@
     ModuleLoadError (..),
     Manifest (..),
     ApplicationId (..),
+    ArtifactOrigin (..),
     AppliedTarget (..),
     BaselineRef (..),
     CommandFingerprint (..),
@@ -67,6 +68,8 @@
   )
 where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict (Map)
 import Data.Set (Set)
 import Data.String (IsString)
@@ -113,28 +116,28 @@
 
 -- | A variable declaration within a module.
 data VarDecl = VarDecl
-  { name :: VarName,
-    type_ :: VarType,
-    default_ :: Maybe VarValue,
-    description :: Maybe Text,
-    required :: Bool,
-    validation :: Maybe Validation
+  { name :: !VarName,
+    type_ :: !VarType,
+    default_ :: !(Maybe VarValue),
+    description :: !(Maybe Text),
+    required :: !Bool,
+    validation :: !(Maybe Validation)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A variable export for cross-module visibility.
 data VarExport = VarExport
-  { var :: VarName,
-    alias :: Maybe VarName
+  { var :: !VarName,
+    alias :: !(Maybe VarName)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | An interactive prompt for a variable.
 data Prompt = Prompt
-  { var :: VarName,
-    text :: Text,
-    condition :: Maybe Expr,
-    choices :: Maybe [Text]
+  { var :: !VarName,
+    text :: !Text,
+    condition :: !(Maybe Expr),
+    choices :: !(Maybe [Text])
   }
   deriving stock (Eq, Show, Generic)
 
@@ -170,43 +173,43 @@
 
 -- | A generation step within a module.
 data Step = Step
-  { strategy :: Strategy,
-    src :: FilePath,
-    dest :: Text,
-    condition :: Maybe Expr,
-    patch :: Maybe PatchOp
+  { strategy :: !Strategy,
+    src :: !FilePath,
+    dest :: !Text,
+    condition :: !(Maybe Expr),
+    patch :: !(Maybe PatchOp)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A shell command to run after file generation.
 data Command = Command
-  { run :: Text,
-    workDir :: Maybe Text,
-    condition :: Maybe Expr
+  { run :: !Text,
+    workDir :: !(Maybe Text),
+    condition :: !(Maybe Expr)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A dependency on another module, optionally supplying variable bindings.
--- When @depVars@ is non-empty, the listed variables are pre-supplied to the
+-- When @vars@ is non-empty, the listed variables are pre-supplied to the
 -- dependency during resolution, sitting between global config and module
 -- defaults in the precedence chain.
 data Dependency = Dependency
-  { depModule :: ModuleName,
-    depVars :: Map VarName Text
+  { module_ :: !ModuleName,
+    vars :: !(Map VarName Text)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | Create a bare dependency with no variable bindings.
 simpleDep :: ModuleName -> Dependency
-simpleDep name = Dependency {depModule = name, depVars = mempty}
+simpleDep name = Dependency {module_ = name, vars = mempty}
 
 -- | Extract module names from a list of dependencies.
 depModuleNames :: [Dependency] -> [ModuleName]
-depModuleNames = map (.depModule)
+depModuleNames = map (^. #module_)
 
 -- | The variable bindings supplied by a dependent module along a specific
 -- dependency edge. This is the "edge decoration" — the identity of a
--- 'ModuleInstance' is determined by the @depVars@ the parent supplied,
+-- 'ModuleInstance' is determined by the @vars@ the parent supplied,
 -- not by anything resolved downstream.
 --
 -- The underlying 'Data.Map.Strict' @Ord@ instance gives structural equality:
@@ -221,9 +224,9 @@
 emptyParentVars :: ParentVars
 emptyParentVars = ParentVars mempty
 
--- | Build 'ParentVars' from a 'Dependency' record's @depVars@ field.
+-- | Build 'ParentVars' from a 'Dependency' record's @vars@ field.
 parentVarsFromDep :: Dependency -> ParentVars
-parentVarsFromDep dep = ParentVars dep.depVars
+parentVarsFromDep dep = ParentVars (dep ^. #vars)
 
 -- | The type of removal action for a removal step.
 data RemovalAction
@@ -237,32 +240,32 @@
 
 -- | A single removal step describing how to reverse one effect of a module.
 data RemovalStep = RemovalStep
-  { action :: RemovalAction,
-    dest :: Text,
-    src :: Maybe FilePath
+  { action :: !RemovalAction,
+    dest :: !Text,
+    src :: !(Maybe FilePath)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | Removal specification for a module.
 data Removal = Removal
-  { removalSteps :: [RemovalStep],
-    removalCommands :: [Command]
+  { steps :: ![RemovalStep],
+    commands :: ![Command]
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A module definition: the fundamental unit of composition.
 data Module = Module
-  { name :: ModuleName,
-    version :: Maybe Text,
-    description :: Maybe Text,
-    vars :: [VarDecl],
-    exports :: [VarExport],
-    prompts :: [Prompt],
-    steps :: [Step],
-    commands :: [Command],
-    dependencies :: [Dependency],
-    removal :: Maybe Removal,
-    migrations :: [Migration]
+  { name :: !ModuleName,
+    version :: !(Maybe Text),
+    description :: !(Maybe Text),
+    vars :: ![VarDecl],
+    exports :: ![VarExport],
+    prompts :: ![Prompt],
+    steps :: ![Step],
+    commands :: ![Command],
+    dependencies :: ![Dependency],
+    removal :: !(Maybe Removal),
+    migrations :: ![Migration]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -275,12 +278,12 @@
 -- | A recipe: a named, reusable composition of modules with optional
 -- pre-configured variable bindings.
 data Recipe = Recipe
-  { name :: RecipeName,
-    version :: Maybe Text,
-    description :: Maybe Text,
-    modules :: [Dependency],
-    vars :: [VarDecl],
-    prompts :: [Prompt]
+  { name :: !RecipeName,
+    version :: !(Maybe Text),
+    description :: !(Maybe Text),
+    modules :: ![Dependency],
+    vars :: ![VarDecl],
+    prompts :: ![Prompt]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -289,8 +292,8 @@
 -- agent's filesystem; @description@ is shown to the agent so it can
 -- pick the right reference for the user's request.
 data BlueprintFile = BlueprintFile
-  { src :: FilePath,
-    description :: Maybe Text
+  { src :: !FilePath,
+    description :: !(Maybe Text)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -300,17 +303,18 @@
 -- name refuses with an actionable message; the agent runner
 -- @seihou agent run@ (EP-31) consumes them instead.
 data Blueprint = Blueprint
-  { name :: ModuleName,
-    version :: Maybe Text,
-    description :: Maybe Text,
-    prompt :: Text,
-    vars :: [VarDecl],
-    prompts :: [Prompt],
-    baseModules :: [Dependency],
-    files :: [BlueprintFile],
-    allowedTools :: Maybe [Text],
-    tags :: [Text],
-    migrations :: [BlueprintMigration]
+  { name :: !ModuleName,
+    version :: !(Maybe Text),
+    description :: !(Maybe Text),
+    prompt :: !Text,
+    vars :: ![VarDecl],
+    prompts :: ![Prompt],
+    baseModules :: ![Dependency],
+    files :: ![BlueprintFile],
+    allowedTools :: !(Maybe [Text]),
+    tags :: ![Text],
+    migrations :: ![BlueprintMigration],
+    launch :: !(Maybe AgentLaunch)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -318,31 +322,37 @@
 -- Process execution is implemented outside the core Dhall decoder; this
 -- record only captures the author-declared command and safety metadata.
 data CommandVar = CommandVar
-  { name :: VarName,
-    run :: Text,
-    workDir :: Maybe Text,
-    condition :: Maybe Expr,
-    trim :: Bool,
-    maxBytes :: Maybe Natural
+  { name :: !VarName,
+    run :: !Text,
+    workDir :: !(Maybe Text),
+    condition :: !(Maybe Expr),
+    trim :: !Bool,
+    maxBytes :: !(Maybe Natural)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A Markdown instruction block attached to an agent prompt. The optional
 -- condition is evaluated after normal and command-derived variables resolve.
 data PromptGuidance = PromptGuidance
-  { title :: Text,
-    body :: Text,
-    condition :: Maybe Expr
+  { title :: !Text,
+    body :: !Text,
+    condition :: !(Maybe Expr)
   }
   deriving stock (Eq, Show, Generic)
 
--- | Optional launch metadata declared by an agent prompt. The CLI runner may
--- use this as a default provider/model/mode hint, but project or CLI config
--- remains authoritative.
-data AgentPromptLaunch = AgentPromptLaunch
-  { provider :: Maybe Text,
-    mode :: Maybe Text,
-    model :: Maybe Text
+-- | Optional launch preferences declared by an agent-driven artifact (a
+-- 'Blueprint' or an 'AgentPrompt'). Values are raw text here; the CLI parses
+-- and validates them, because the provider and effort vocabularies live in the
+-- CLI layer. 'mode' is reserved and currently ignored.
+--
+-- Declared values override the invoking user's configuration files but lose to
+-- a @--provider@ \/ @--model@ \/ @--effort@ flag and to the @SEIHOU_AGENT_*@
+-- environment variables.
+data AgentLaunch = AgentLaunch
+  { provider :: !(Maybe Text),
+    model :: !(Maybe Text),
+    effort :: !(Maybe Text),
+    mode :: !(Maybe Text)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -350,18 +360,18 @@
 -- does not declare baseline modules and does not imply scaffolding or
 -- manifest provenance.
 data AgentPrompt = AgentPrompt
-  { name :: ModuleName,
-    version :: Maybe Text,
-    description :: Maybe Text,
-    prompt :: Text,
-    vars :: [VarDecl],
-    prompts :: [Prompt],
-    commandVars :: [CommandVar],
-    guidance :: [PromptGuidance],
-    files :: [BlueprintFile],
-    allowedTools :: Maybe [Text],
-    tags :: [Text],
-    launch :: Maybe AgentPromptLaunch
+  { name :: !ModuleName,
+    version :: !(Maybe Text),
+    description :: !(Maybe Text),
+    prompt :: !Text,
+    vars :: ![VarDecl],
+    prompts :: ![Prompt],
+    commandVars :: ![CommandVar],
+    guidance :: ![PromptGuidance],
+    files :: ![BlueprintFile],
+    allowedTools :: !(Maybe [Text]),
+    tags :: ![Text],
+    launch :: !(Maybe AgentLaunch)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -380,29 +390,29 @@
 -- | Filesystem operations produced by the generation engine.
 data Operation
   = WriteFileOp
-      { dest :: FilePath,
-        content :: Text,
-        strategy :: Strategy
+      { dest :: !FilePath,
+        content :: !Text,
+        strategy :: !Strategy
       }
   | CreateDirOp
-      { path :: FilePath
+      { path :: !FilePath
       }
   | CopyFileOp
-      { src :: FilePath,
-        dest :: FilePath
+      { src :: !FilePath,
+        dest :: !FilePath
       }
   | RunCommandOp
-      { command :: Text,
-        workDir :: Maybe FilePath,
-        moduleName :: ModuleName,
-        occurrence :: Int
+      { command :: !Text,
+        workDir :: !(Maybe FilePath),
+        moduleName :: !ModuleName,
+        occurrence :: !Int
       }
   | PatchFileOp
-      { dest :: FilePath,
-        content :: Text,
-        op :: PatchOp,
-        strategy :: Strategy,
-        moduleName :: ModuleName
+      { dest :: !FilePath,
+        content :: !Text,
+        op :: !PatchOp,
+        strategy :: !Strategy,
+        moduleName :: !ModuleName
       }
   deriving stock (Eq, Show, Generic)
 
@@ -434,9 +444,9 @@
 
 -- | A variable that has been resolved to a concrete value with provenance.
 data ResolvedVar = ResolvedVar
-  { value :: VarValue,
-    source :: VarSource,
-    decl :: VarDecl
+  { value :: !VarValue,
+    source :: !VarSource,
+    decl :: !VarDecl
   }
   deriving stock (Eq, Show, Generic)
 
@@ -465,15 +475,15 @@
 -- | Tracks the state of generated files for incremental re-generation
 -- and conflict detection. Stored at @.seihou/manifest.json@.
 data Manifest = Manifest
-  { version :: Int,
-    genAt :: UTCTime,
-    modules :: [AppliedModule],
-    vars :: Map VarName Text,
-    files :: Map FilePath FileRecord,
-    applications :: [AppliedComposition],
-    recipe :: Maybe AppliedRecipe,
-    blueprint :: Maybe AppliedBlueprint,
-    blueprintMigrations :: [AppliedBlueprintMigration]
+  { version :: !Int,
+    genAt :: !UTCTime,
+    modules :: ![AppliedModule],
+    vars :: !(Map VarName Text),
+    files :: !(Map FilePath FileRecord),
+    applications :: ![AppliedComposition],
+    recipe :: !(Maybe AppliedRecipe),
+    blueprint :: !(Maybe AppliedBlueprint),
+    blueprintMigrations :: ![AppliedBlueprintMigration]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -481,6 +491,43 @@
 newtype ApplicationId = ApplicationId {unApplicationId :: Text}
   deriving stock (Eq, Ord, Show, Generic)
 
+-- | Machine-independent identity of an artifact recorded in the manifest.
+--
+-- The manifest is checked into version control and shared between
+-- developers, so it must never contain a path that is meaningful only on
+-- the machine that wrote it. Every artifact reference is therefore one of
+-- three cases, distinguished by how much provenance seihou can actually
+-- prove.
+--
+-- 'RemoteOrigin' is the strong case: the artifact was installed by
+-- @seihou install@ from a git URL into
+-- @~\/.config\/seihou\/installed\/\<name\>@, and that URL was recorded in
+-- @.seihou-origin.json@ beside it. Two developers who install from the
+-- same URL are provably using the same upstream artifact.
+--
+-- 'ProjectOrigin' is the case where the artifact lives inside the project
+-- itself, under @.seihou\/modules\/\<name\>@. The path is stored relative
+-- to the project root, so it means the same thing in every clone.
+--
+-- 'LocalOrigin' is the weak case: the artifact was found in the
+-- developer's personal @~\/.config\/seihou\/modules\/@ directory, which
+-- carries no provenance metadata at all. Only the name is knowable.
+-- Recording it honestly, rather than fabricating a URL, lets later
+-- verification report that this artifact's provenance cannot be checked.
+data ArtifactOrigin
+  = RemoteOrigin
+      { originUrl :: !Text,
+        artifactName :: !Text,
+        repoName :: !(Maybe Text)
+      }
+  | ProjectOrigin
+      { relativePath :: !FilePath
+      }
+  | LocalOrigin
+      { artifactName :: !Text
+      }
+  deriving stock (Eq, Ord, Show, Generic)
+
 -- | The deterministic artifact originally requested by the user.
 data AppliedTarget
   = AppliedModuleTarget ModuleName
@@ -497,44 +544,48 @@
 
 -- | Evidence that one rendered command completed successfully.
 data CommandReceipt = CommandReceipt
-  { fingerprint :: CommandFingerprint,
-    moduleName :: ModuleName,
-    command :: Text,
-    workDir :: Maybe FilePath,
-    completedAt :: UTCTime
+  { fingerprint :: !CommandFingerprint,
+    moduleName :: !ModuleName,
+    command :: !Text,
+    workDir :: !(Maybe FilePath),
+    completedAt :: !UTCTime
   }
   deriving stock (Eq, Show, Generic)
 
 -- | Reproducible state for one module instance in an application.
+--
+-- @origin@ is the module's portable identity. Turning it back into a
+-- directory on the current machine is 'Seihou.Core.ArtifactRef.resolveArtifactOrigin';
+-- no path is ever recorded here.
 data AppliedInstanceState = AppliedInstanceState
-  { name :: ModuleName,
-    parentVars :: ParentVars,
-    source :: FilePath,
-    moduleVersion :: Maybe Text,
-    resolvedVars :: Map VarName Text
+  { name :: !ModuleName,
+    parentVars :: !ParentVars,
+    origin :: !ArtifactOrigin,
+    moduleVersion :: !(Maybe Text),
+    resolvedVars :: !(Map VarName Text)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A complete, re-runnable top-level module or recipe composition.
 data AppliedComposition = AppliedComposition
-  { applicationId :: ApplicationId,
-    target :: AppliedTarget,
-    targetSource :: FilePath,
-    targetVersion :: Maybe Text,
-    additionalModules :: [ModuleName],
-    namespace :: Maybe Text,
-    context :: Maybe Text,
-    instances :: [AppliedInstanceState],
-    commandReceipts :: Map CommandFingerprint CommandReceipt,
-    appliedAt :: UTCTime
+  { applicationId :: !ApplicationId,
+    target :: !AppliedTarget,
+    targetOrigin :: !ArtifactOrigin,
+    targetVersion :: !(Maybe Text),
+    additionalModules :: ![ModuleName],
+    namespace :: !(Maybe Text),
+    context :: !(Maybe Text),
+    instances :: ![AppliedInstanceState],
+    commandReceipts :: !(Map CommandFingerprint CommandReceipt),
+    appliedAt :: !UTCTime
   }
   deriving stock (Eq, Show, Generic)
 
 -- | Recipe provenance recorded in the manifest when a recipe is used.
 data AppliedRecipe = AppliedRecipe
-  { name :: RecipeName,
-    recipeVersion :: Maybe Text,
-    appliedAt :: UTCTime
+  { name :: !RecipeName,
+    recipeVersion :: !(Maybe Text),
+    appliedAt :: !UTCTime
   }
   deriving stock (Eq, Show, Generic)
 
@@ -552,13 +603,13 @@
 -- in @docs/masterplans/3-agent-driven-blueprints.md@; in v1 it is always
 -- 'Nothing' and the encoder omits the JSON key in that case.
 data AppliedBlueprint = AppliedBlueprint
-  { name :: ModuleName,
-    blueprintVersion :: Maybe Text,
-    appliedAt :: UTCTime,
-    baselineModules :: [ModuleName],
-    noBaseline :: Bool,
-    userPrompt :: Maybe Text,
-    agentSessionId :: Maybe Text
+  { name :: !ModuleName,
+    blueprintVersion :: !(Maybe Text),
+    appliedAt :: !UTCTime,
+    baselineModules :: ![ModuleName],
+    noBaseline :: !Bool,
+    userPrompt :: !(Maybe Text),
+    agentSessionId :: !(Maybe Text)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -566,12 +617,12 @@
 -- migration edge. Exact-edge identity is the blueprint 'name' together with
 -- 'fromVersion' and 'toVersion'; the remaining fields are audit metadata.
 data AppliedBlueprintMigration = AppliedBlueprintMigration
-  { name :: ModuleName,
-    blueprintVersion :: Maybe Text,
-    fromVersion :: Text,
-    toVersion :: Text,
-    appliedAt :: UTCTime,
-    agentSessionId :: Maybe Text
+  { name :: !ModuleName,
+    blueprintVersion :: !(Maybe Text),
+    fromVersion :: !Text,
+    toVersion :: !Text,
+    appliedAt :: !UTCTime,
+    agentSessionId :: !(Maybe Text)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -582,24 +633,29 @@
 -- the same @name@ and different @parentVars@ represent two legitimate
 -- instances. Manifests produced before schema version 2 decode with
 -- @parentVars = 'emptyParentVars'@.
+--
+-- @origin@ is the module's portable identity. Turning it back into a
+-- directory on the current machine is
+-- 'Seihou.Core.ArtifactRef.resolveArtifactOrigin'; no path is ever recorded
+-- here.
 data AppliedModule = AppliedModule
-  { name :: ModuleName,
-    parentVars :: ParentVars,
-    source :: FilePath,
-    moduleVersion :: Maybe Text,
-    appliedAt :: UTCTime,
-    removal :: Maybe Removal
+  { name :: !ModuleName,
+    parentVars :: !ParentVars,
+    origin :: !ArtifactOrigin,
+    moduleVersion :: !(Maybe Text),
+    appliedAt :: !UTCTime,
+    removal :: !(Maybe Removal)
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A record of a generated file, stored in the manifest.
 data FileRecord = FileRecord
-  { hash :: SHA256,
-    moduleName :: ModuleName,
-    strategy :: Strategy,
-    generatedAt :: UTCTime,
-    baseline :: Maybe BaselineRef,
-    applicationIds :: Set ApplicationId
+  { hash :: !SHA256,
+    moduleName :: !ModuleName,
+    strategy :: !Strategy,
+    generatedAt :: !UTCTime,
+    baseline :: !(Maybe BaselineRef),
+    applicationIds :: !(Set ApplicationId)
   }
   deriving stock (Eq, Show, Generic)
 
@@ -610,47 +666,47 @@
 
 -- | Result of the three-state diff: manifest vs plan vs disk.
 data DiffResult = DiffResult
-  { new :: [PlannedFile],
-    modified :: [ModifiedFile],
-    unchanged :: [FilePath],
-    conflicts :: [ConflictFile],
-    orphaned :: [OrphanedFile]
+  { new :: ![PlannedFile],
+    modified :: ![ModifiedFile],
+    unchanged :: ![FilePath],
+    conflicts :: ![ConflictFile],
+    orphaned :: ![OrphanedFile]
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A file that exists in the plan but not in the manifest or on disk.
 data PlannedFile = PlannedFile
-  { path :: FilePath,
-    moduleName :: ModuleName,
-    content :: Text
+  { path :: !FilePath,
+    moduleName :: !ModuleName,
+    content :: !Text
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A file that has changed between the manifest and the plan,
 -- but the user has not modified the disk copy.
 data ModifiedFile = ModifiedFile
-  { path :: FilePath,
-    moduleName :: ModuleName,
-    oldHash :: SHA256,
-    newContent :: Text
+  { path :: !FilePath,
+    moduleName :: !ModuleName,
+    oldHash :: !SHA256,
+    newContent :: !Text
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A file where the user has modified the disk copy since it was generated.
 data ConflictFile = ConflictFile
-  { path :: FilePath,
-    moduleName :: ModuleName,
-    manifestHash :: SHA256,
-    diskHash :: SHA256,
-    planContent :: Text
+  { path :: !FilePath,
+    moduleName :: !ModuleName,
+    manifestHash :: !SHA256,
+    diskHash :: !SHA256,
+    planContent :: !Text
   }
   deriving stock (Eq, Show, Generic)
 
 -- | A file that exists in the manifest but not in the current plan
 -- (the module that generated it was removed or no longer produces it).
 data OrphanedFile = OrphanedFile
-  { path :: FilePath,
-    moduleName :: ModuleName
+  { path :: !FilePath,
+    moduleName :: !ModuleName
   }
   deriving stock (Eq, Show, Generic)
 
@@ -705,8 +761,8 @@
 
 -- | A tracked file with its path, originating module, and disk status.
 data TrackedFile = TrackedFile
-  { path :: FilePath,
-    moduleName :: ModuleName,
-    status :: TrackedFileStatus
+  { path :: !FilePath,
+    moduleName :: !ModuleName,
+    status :: !TrackedFileStatus
   }
   deriving stock (Eq, Show, Generic)
diff --git a/src/Seihou/Core/Variable.hs b/src/Seihou/Core/Variable.hs
--- a/src/Seihou/Core/Variable.hs
+++ b/src/Seihou/Core/Variable.hs
@@ -12,6 +12,7 @@
 where
 
 import Data.Char (toUpper)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Maybe (catMaybes)
 import Data.Set qualified as Set
@@ -69,9 +70,9 @@
 -- | Validate a resolved value against its declaration's validation constraint.
 validateVarValue :: VarDecl -> VarValue -> Either VarError ()
 validateVarValue decl val =
-  case decl.validation of
+  case decl ^. #validation of
     Nothing -> Right ()
-    Just v -> checkValidation decl.name v val
+    Just v -> checkValidation (decl ^. #name) v val
 
 checkValidation :: VarName -> Validation -> VarValue -> Either VarError ()
 checkValidation name (ValPattern pat) (VText t) =
@@ -185,8 +186,8 @@
   where
     resolveOne :: VarDecl -> Either VarError (Maybe (VarName, ResolvedVar))
     resolveOne decl =
-      let name = decl.name
-          ty = decl.type_
+      let name = (decl ^. #name)
+          ty = (decl ^. #type_)
        in case lookupCLI name ty of
             Just result -> fmap Just (result >>= validateAndWrap decl)
             Nothing -> case lookupSaved name ty of
@@ -203,13 +204,13 @@
                         Just result -> fmap Just (result >>= validateAndWrap decl)
                         Nothing -> case lookupParent name ty of
                           Just result -> fmap Just (result >>= validateAndWrap decl)
-                          Nothing -> case decl.default_ of
+                          Nothing -> case decl ^. #default_ of
                             Just defVal ->
                               case coerceDefault name ty defVal of
                                 Left err -> Left err
                                 Right val -> fmap Just (validateAndWrap decl (val, FromDefault))
                             Nothing
-                              | decl.required -> Left (MissingRequiredVar name)
+                              | decl ^. #required -> Left (MissingRequiredVar name)
                               | otherwise -> Right Nothing
 
     lookupSaved :: VarName -> VarType -> Maybe (Either VarError (VarValue, VarSource))
@@ -264,7 +265,7 @@
         Left err -> Left err
         Right () ->
           Right
-            ( decl.name,
+            ( decl ^. #name,
               ResolvedVar
                 { value = val,
                   source = source,
@@ -289,14 +290,14 @@
 
     -- Calculate column widths for alignment
     maxNameLen = maximum (0 : map (\(VarName n, _) -> T.length n) entries)
-    maxValueLen = maximum (0 : map (\(_, rv) -> T.length (showValue rv.value)) entries)
+    maxValueLen = maximum (0 : map (\(_, rv) -> T.length (showValue (rv ^. #value))) entries)
 
     formatOne :: (VarName, ResolvedVar) -> Text
     formatOne (VarName n, rv) =
-      let valText = showValue rv.value
+      let valText = showValue (rv ^. #value)
           namePad = T.replicate (maxNameLen - T.length n) " "
           valPad = T.replicate (maxValueLen - T.length valText) " "
-       in "  " <> n <> namePad <> " = " <> valText <> valPad <> "  " <> showSource rv.source
+       in "  " <> n <> namePad <> " = " <> valText <> valPad <> "  " <> showSource (rv ^. #source)
 
     showValue :: VarValue -> Text
     showValue (VText t) = "\"" <> t <> "\""
@@ -313,7 +314,7 @@
     showSource (FromNamespaceConfig ns) = "[namespace: " <> ns <> "]"
     showSource (FromContextConfig ctx) = "[context: " <> ctx <> "]"
     showSource FromGlobalConfig = "[global config]"
-    showSource (FromParent mn) = "[parent: " <> mn.unModuleName <> "]"
+    showSource (FromParent mn) = "[parent: " <> mn ^. #unModuleName <> "]"
     showSource FromDefault = "[default]"
     showSource FromPrompt = "[prompt]"
     showSource (FromCommand cmd) = "[command: " <> cmd <> "]"
@@ -324,15 +325,15 @@
 formatDeclarations decls =
   T.unlines (map formatOne decls)
   where
-    maxNameLen = maximum (0 : map (\d -> T.length d.name.unVarName) decls)
+    maxNameLen = maximum (0 : map (\d -> T.length (d ^. #name . #unVarName)) decls)
 
     formatOne :: VarDecl -> Text
     formatOne d =
-      let VarName n = d.name
+      let VarName n = (d ^. #name)
           namePad = T.replicate (maxNameLen - T.length n) " "
-          valText = case d.default_ of
+          valText = case d ^. #default_ of
             Nothing
-              | d.required -> "(required, no default)"
+              | d ^. #required -> "(required, no default)"
               | otherwise -> "(optional, no default)"
             Just v -> showDeclValue v
        in "  " <> n <> namePad <> " = " <> valText
@@ -362,15 +363,15 @@
 diagnoseResolution resolved decls localConfig nsConfig ctxConfig globalConfig =
   (unusedConfigKeys, unresolvedOptional)
   where
-    declaredNames = Set.fromList (map (.name) decls)
+    declaredNames = Set.fromList (map (^. #name) decls)
     allConfigKeys =
       Set.fromList $
         Map.keys localConfig ++ Map.keys nsConfig ++ Map.keys ctxConfig ++ Map.keys globalConfig
     unusedConfigKeys =
       Set.toAscList (allConfigKeys `Set.difference` declaredNames)
     unresolvedOptional =
-      [ d.name
+      [ d ^. #name
       | d <- decls,
-        not d.required,
-        not (Map.member d.name resolved)
+        not (d ^. #required),
+        not (Map.member (d ^. #name) resolved)
       ]
diff --git a/src/Seihou/Core/Version.hs b/src/Seihou/Core/Version.hs
--- a/src/Seihou/Core/Version.hs
+++ b/src/Seihou/Core/Version.hs
@@ -6,7 +6,6 @@
 where
 
 import Data.Text qualified as T
-import GHC.Generics (Generic)
 import Numeric.Natural (Natural)
 import Seihou.Prelude
 
diff --git a/src/Seihou/Dhall/Eval.hs b/src/Seihou/Dhall/Eval.hs
--- a/src/Seihou/Dhall/Eval.hs
+++ b/src/Seihou/Dhall/Eval.hs
@@ -11,7 +11,7 @@
     agentPromptDecoder,
     commandVarDecoder,
     promptGuidanceDecoder,
-    agentPromptLaunchDecoder,
+    agentLaunchDecoder,
     blueprintFileDecoder,
     registryDecoder,
     registryEntryDecoder,
@@ -35,6 +35,7 @@
 
 import Control.Exception (SomeException, evaluate, throwIO, try)
 import Data.Either.Validation (Validation (..))
+import Data.Generics.Labels ()
 import Data.List (foldl')
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
@@ -96,10 +97,10 @@
     case extract moduleDecoder expr of
       Success m -> do
         -- Force lazy decoder thunks that may contain 'error' calls
-        mapM_ (\v -> evaluate v.type_) m.vars
-        mapM_ (\s -> evaluate s.strategy >> evaluate s.condition >> mapM_ evaluate s.patch) m.steps
-        mapM_ (\c -> mapM_ evaluate c.condition) m.commands
-        mapM_ (\p -> evaluate p.condition) m.prompts
+        mapM_ (\v -> evaluate (v ^. #type_)) (m ^. #vars)
+        mapM_ (\s -> evaluate (s ^. #strategy) >> evaluate (s ^. #condition) >> mapM_ evaluate (s ^. #patch)) (m ^. #steps)
+        mapM_ (\c -> mapM_ evaluate (c ^. #condition)) (m ^. #commands)
+        mapM_ (\p -> evaluate (p ^. #condition)) (m ^. #prompts)
         pure m
       Failure e -> throwIO e
   case result of
@@ -125,8 +126,8 @@
     case extract recipeDecoder expr of
       Success r -> do
         -- Force lazy decoder thunks that may contain 'error' calls
-        mapM_ (\v -> evaluate v.type_) r.vars
-        mapM_ (\p -> evaluate p.condition) r.prompts
+        mapM_ (\v -> evaluate (v ^. #type_)) (r ^. #vars)
+        mapM_ (\p -> evaluate (p ^. #condition)) (r ^. #prompts)
         pure r
       Failure e -> throwIO e
   case result of
@@ -276,9 +277,11 @@
     )
 
 -- | Decoder for the top-level Blueprint type from Dhall.
+-- Uses 'withDefaults' to handle blueprints that predate the @migrations@ and
+-- @launch@ fields.
 blueprintDecoder :: Decoder Blueprint
 blueprintDecoder =
-  withDefaults [("migrations", emptyMigrationList)] $
+  withDefaults [("migrations", emptyMigrationList), ("launch", noneText)] $
     record
       ( Blueprint
           <$> field "name" moduleNameDecoder
@@ -292,6 +295,7 @@
           <*> field "allowedTools" (maybe (list strictText))
           <*> field "tags" (list strictText)
           <*> field "migrations" (list blueprintMigrationDecoder)
+          <*> field "launch" (maybe agentLaunchDecoder)
       )
 
 -- | Evaluate a @blueprint.dhall@ file and decode it into a 'Blueprint'.
@@ -311,8 +315,8 @@
     expr <- inputExprWithSettings settings text
     case extract blueprintDecoder expr of
       Success b -> do
-        mapM_ (\v -> evaluate v.type_) b.vars
-        mapM_ (\p -> evaluate p.condition) b.prompts
+        mapM_ (\v -> evaluate (v ^. #type_)) (b ^. #vars)
+        mapM_ (\p -> evaluate (p ^. #condition)) (b ^. #prompts)
         pure b
       Failure e -> throwIO e
   case result of
@@ -344,15 +348,19 @@
           maxBytes = maxBytes
         }
 
--- | Decoder for optional agent prompt launch metadata.
-agentPromptLaunchDecoder :: Decoder AgentPromptLaunch
-agentPromptLaunchDecoder =
-  record
-    ( AgentPromptLaunch
-        <$> field "provider" (maybe strictText)
-        <*> field "mode" (maybe strictText)
-        <*> field "model" (maybe strictText)
-    )
+-- | Decoder for the shared launch record declared by a 'Blueprint' or an
+-- 'AgentPrompt'. @effort@ and @mode@ are defaulted so artifacts authored
+-- against a schema pin that predates them still decode.
+agentLaunchDecoder :: Decoder AgentLaunch
+agentLaunchDecoder =
+  withDefaults [("effort", noneText), ("mode", noneText)] $
+    record
+      ( AgentLaunch
+          <$> field "provider" (maybe strictText)
+          <*> field "model" (maybe strictText)
+          <*> field "effort" (maybe strictText)
+          <*> field "mode" (maybe strictText)
+      )
 
 -- | Decoder for a prompt guidance block.
 promptGuidanceDecoder :: Decoder PromptGuidance
@@ -388,7 +396,7 @@
           <*> field "files" (list blueprintFileDecoder)
           <*> field "allowedTools" (maybe (list strictText))
           <*> field "tags" (list strictText)
-          <*> field "launch" (maybe agentPromptLaunchDecoder)
+          <*> field "launch" (maybe agentLaunchDecoder)
       )
 
 emptyPromptGuidanceList :: Dhall.Expr Src Void
@@ -405,10 +413,10 @@
     expr <- inputExprWithSettings settings text
     case extract agentPromptDecoder expr of
       Success p -> do
-        mapM_ (\v -> evaluate v.type_) p.vars
-        mapM_ (\prompt -> evaluate prompt.condition) p.prompts
-        mapM_ (\cv -> evaluate cv.condition) p.commandVars
-        mapM_ (\g -> evaluate g.condition) p.guidance
+        mapM_ (\v -> evaluate (v ^. #type_)) (p ^. #vars)
+        mapM_ (\prompt -> evaluate (prompt ^. #condition)) (p ^. #prompts)
+        mapM_ (\cv -> evaluate (cv ^. #condition)) (p ^. #commandVars)
+        mapM_ (\g -> evaluate (g ^. #condition)) (p ^. #guidance)
         pure p
       Failure e -> throwIO e
   case result of
@@ -491,7 +499,7 @@
         )
 
     mkDep :: ModuleName -> [(VarName, Text)] -> Dependency
-    mkDep name bindings = Dependency {depModule = name, depVars = Map.fromList bindings}
+    mkDep name bindings = Dependency {module_ = name, vars = Map.fromList bindings}
 
 -- | Decoder for VarType from a Dhall Text string.
 -- Dhall does not support recursive types, so VarType is represented as a
@@ -560,25 +568,25 @@
 -- 'evalModuleFromFile' and surfaced as a 'DhallEvalError'.
 coerceDeclDefault :: VarDecl -> VarDecl
 coerceDeclDefault decl =
-  case decl.default_ of
+  case decl ^. #default_ of
     Nothing -> decl
     Just rawDefault ->
-      case coerceDefault decl.name decl.type_ rawDefault of
-        Right val -> decl {default_ = Just val}
+      case coerceDefault (decl ^. #name) (decl ^. #type_) rawDefault of
+        Right val -> decl & #default_ ?~ val
         -- Caught by 'try' in 'evalModuleFromFile'
-        Left err -> error (T.unpack (renderDefaultError decl.name err))
+        Left err -> error (T.unpack (renderDefaultError (decl ^. #name) err))
 
 -- | Render a coercion failure for a module default into a load-time message.
 renderDefaultError :: VarName -> VarError -> Text
 renderDefaultError name (CoercionFailed _ ty raw) =
   "Invalid default for variable '"
-    <> name.unVarName
+    <> name ^. #unVarName
     <> "': cannot coerce "
     <> T.pack (show raw)
     <> " to declared type "
     <> renderVarType ty
 renderDefaultError name err =
-  "Invalid default for variable '" <> name.unVarName <> "': " <> T.pack (show err)
+  "Invalid default for variable '" <> name ^. #unVarName <> "': " <> T.pack (show err)
 
 -- | A short rendering of a declared variable type for error messages.
 renderVarType :: VarType -> Text
diff --git a/src/Seihou/Effect/BaselineStoreInterp.hs b/src/Seihou/Effect/BaselineStoreInterp.hs
--- a/src/Seihou/Effect/BaselineStoreInterp.hs
+++ b/src/Seihou/Effect/BaselineStoreInterp.hs
@@ -4,6 +4,7 @@
 where
 
 import Control.Monad (filterM, unless, when)
+import Data.Generics.Labels ()
 import Data.Maybe (mapMaybe)
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -33,7 +34,7 @@
     finalExists <- doesFileExist finalPath
     reusable <-
       if finalExists
-        then ((== ref.unBaselineRef) . hashContent) <$> readFileText finalPath
+        then ((== ref ^. #unBaselineRef) . hashContent) <$> readFileText finalPath
         else pure False
     unless reusable $ do
       writeFileText tempPath content
@@ -50,7 +51,7 @@
           else do
             content <- readFileText path
             let actual = hashContent content
-            if actual == ref.unBaselineRef
+            if actual == ref ^. #unBaselineRef
               then pure (Right content)
               else pure (Left (BaselineCorrupt ref actual))
   PruneBaselines referenced -> do
@@ -71,12 +72,12 @@
           isFile <- doesFileExist path
           if not isFile
             then pure False
-            else ((== ref.unBaselineRef) . hashContent) <$> readFileText path
+            else ((== ref ^. #unBaselineRef) . hashContent) <$> readFileText path
 
 baselinePath :: FilePath -> BaselineRef -> Maybe FilePath
 baselinePath root ref = do
-  normalized <- baselineRefFromText ref.unBaselineRef.unSHA256
-  pure (root </> T.unpack normalized.unBaselineRef.unSHA256)
+  normalized <- baselineRefFromText (ref ^. #unBaselineRef . #unSHA256)
+  pure (root </> T.unpack (normalized ^. #unBaselineRef . #unSHA256))
 
 checkedBaselinePath :: FilePath -> BaselineRef -> FilePath
 checkedBaselinePath root ref = case baselinePath root ref of
diff --git a/src/Seihou/Effect/BaselineStorePure.hs b/src/Seihou/Effect/BaselineStorePure.hs
--- a/src/Seihou/Effect/BaselineStorePure.hs
+++ b/src/Seihou/Effect/BaselineStorePure.hs
@@ -3,6 +3,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict (Map)
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
@@ -32,7 +33,7 @@
           Nothing -> Left (BaselineMissing ref)
           Just content ->
             let actual = hashContent content
-             in if actual == ref.unBaselineRef
+             in if actual == ref ^. #unBaselineRef
                   then Right content
                   else Left (BaselineCorrupt ref actual)
       PruneBaselines referenced -> do
@@ -40,7 +41,7 @@
         let removable =
               Map.keysSet $
                 Map.filterWithKey
-                  (\ref content -> Set.notMember ref referenced && hashContent content == ref.unBaselineRef)
+                  (\ref content -> Set.notMember ref referenced && hashContent content == ref ^. #unBaselineRef)
                   store
         modify @(Map BaselineRef Text) (`Map.withoutKeys` removable)
         pure (Set.toAscList removable)
diff --git a/src/Seihou/Effect/ConfigWriterPure.hs b/src/Seihou/Effect/ConfigWriterPure.hs
--- a/src/Seihou/Effect/ConfigWriterPure.hs
+++ b/src/Seihou/Effect/ConfigWriterPure.hs
@@ -5,6 +5,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Effectful.State.Static.Local (State, get, modify, runState)
 import Seihou.Core.Types (ConfigScope (..))
@@ -13,19 +14,19 @@
 
 -- | In-memory state for the pure ConfigWriter interpreter.
 data ConfigWriterState = ConfigWriterState
-  { cwLocal :: Map Text Text,
-    cwNamespaces :: Map Text (Map Text Text),
-    cwGlobal :: Map Text Text
+  { local :: !(Map Text Text),
+    namespaces :: !(Map Text (Map Text Text)),
+    global :: !(Map Text Text)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Empty initial state with no config values in any scope.
 emptyConfigWriterState :: ConfigWriterState
 emptyConfigWriterState =
   ConfigWriterState
-    { cwLocal = Map.empty,
-      cwNamespaces = Map.empty,
-      cwGlobal = Map.empty
+    { local = Map.empty,
+      namespaces = Map.empty,
+      global = Map.empty
     }
 
 -- | Pure interpreter for the ConfigWriter effect using in-memory state.
@@ -46,22 +47,22 @@
         pure (Right (readScope scope st))
 
 writeToScope :: ConfigScope -> Text -> Text -> ConfigWriterState -> ConfigWriterState
-writeToScope ScopeLocal key val st = st {cwLocal = Map.insert key val st.cwLocal}
+writeToScope ScopeLocal key val st = st & #local . at key ?~ val
 writeToScope (ScopeNamespace ns) key val st =
-  let nsMap = Map.findWithDefault Map.empty ns st.cwNamespaces
+  let nsMap = Map.findWithDefault Map.empty ns (st ^. #namespaces)
       updated = Map.insert key val nsMap
-   in st {cwNamespaces = Map.insert ns updated st.cwNamespaces}
-writeToScope ScopeGlobal key val st = st {cwGlobal = Map.insert key val st.cwGlobal}
+   in st & #namespaces . at ns ?~ updated
+writeToScope ScopeGlobal key val st = st & #global . at key ?~ val
 
 deleteFromScope :: ConfigScope -> Text -> ConfigWriterState -> ConfigWriterState
-deleteFromScope ScopeLocal key st = st {cwLocal = Map.delete key st.cwLocal}
+deleteFromScope ScopeLocal key st = st & #local . at key .~ Nothing
 deleteFromScope (ScopeNamespace ns) key st =
-  let nsMap = Map.findWithDefault Map.empty ns st.cwNamespaces
+  let nsMap = Map.findWithDefault Map.empty ns (st ^. #namespaces)
       updated = Map.delete key nsMap
-   in st {cwNamespaces = Map.insert ns updated st.cwNamespaces}
-deleteFromScope ScopeGlobal key st = st {cwGlobal = Map.delete key st.cwGlobal}
+   in st & #namespaces . at ns ?~ updated
+deleteFromScope ScopeGlobal key st = st & #global . at key .~ Nothing
 
 readScope :: ConfigScope -> ConfigWriterState -> Map Text Text
-readScope ScopeLocal st = st.cwLocal
-readScope (ScopeNamespace ns) st = Map.findWithDefault Map.empty ns st.cwNamespaces
-readScope ScopeGlobal st = st.cwGlobal
+readScope ScopeLocal st = st ^. #local
+readScope (ScopeNamespace ns) st = Map.findWithDefault Map.empty ns (st ^. #namespaces)
+readScope ScopeGlobal st = st ^. #global
diff --git a/src/Seihou/Effect/ConsolePure.hs b/src/Seihou/Effect/ConsolePure.hs
--- a/src/Seihou/Effect/ConsolePure.hs
+++ b/src/Seihou/Effect/ConsolePure.hs
@@ -6,6 +6,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Effectful.State.Static.Local (State, get, modify, runState)
 import Seihou.Effect.Console (Console (..))
 import Seihou.Prelude
@@ -13,11 +14,11 @@
 
 -- | State for the pure Console interpreter.
 data ConsoleState = ConsoleState
-  { consoleInputs :: [Text],
-    consoleOutputs :: [Text],
-    consoleErrors :: [Text]
+  { inputs :: ![Text],
+    outputs :: ![Text],
+    errors :: ![Text]
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Empty console state with no inputs or outputs.
 emptyConsoleState :: ConsoleState
@@ -30,8 +31,8 @@
   where
     handler :: (State ConsoleState :> es') => EffectHandler Console es'
     handler _ = \case
-      PutText msg -> modify @ConsoleState (\s -> s {consoleOutputs = s.consoleOutputs ++ [msg]})
-      PutError msg -> modify @ConsoleState (\s -> s {consoleErrors = s.consoleErrors ++ [msg]})
+      PutText msg -> modify @ConsoleState (\s -> s & #outputs %~ (<> [msg]))
+      PutError msg -> modify @ConsoleState (\s -> s & #errors %~ (<> [msg]))
       GetLine -> popInput
       Confirm _prompt -> (`elem` ["y", "yes"]) <$> popInput
       IsInteractive -> pure True
@@ -39,10 +40,10 @@
     popInput :: (State ConsoleState :> es') => Eff es' Text
     popInput = do
       s <- get @ConsoleState
-      case s.consoleInputs of
+      case s ^. #inputs of
         [] -> pure ""
         (x : xs) -> do
-          modify @ConsoleState (\st -> st {consoleInputs = xs})
+          modify @ConsoleState (\st -> st & #inputs .~ xs)
           pure x
 
 -- | Pure interpreter for non-interactive mode. IsInteractive returns False.
@@ -51,8 +52,8 @@
   where
     handler :: (State ConsoleState :> es') => EffectHandler Console es'
     handler _ = \case
-      PutText msg -> modify @ConsoleState (\s -> s {consoleOutputs = s.consoleOutputs ++ [msg]})
-      PutError msg -> modify @ConsoleState (\s -> s {consoleErrors = s.consoleErrors ++ [msg]})
+      PutText msg -> modify @ConsoleState (\s -> s & #outputs %~ (<> [msg]))
+      PutError msg -> modify @ConsoleState (\s -> s & #errors %~ (<> [msg]))
       GetLine -> pure ""
       Confirm _prompt -> pure False
       IsInteractive -> pure False
diff --git a/src/Seihou/Effect/FilesystemPure.hs b/src/Seihou/Effect/FilesystemPure.hs
--- a/src/Seihou/Effect/FilesystemPure.hs
+++ b/src/Seihou/Effect/FilesystemPure.hs
@@ -5,6 +5,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Effectful.State.Static.Local (State, get, modify, put, runState)
@@ -13,10 +14,10 @@
 
 -- | In-memory filesystem state for testing.
 data PureFS = PureFS
-  { files :: Map FilePath Text,
-    dirs :: Set FilePath
+  { files :: !(Map FilePath Text),
+    dirs :: !(Set FilePath)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | An empty in-memory filesystem.
 emptyFS :: PureFS
@@ -31,50 +32,50 @@
     handler _ = \case
       ReadFileText path -> do
         fs <- get @PureFS
-        case Map.lookup path fs.files of
+        case Map.lookup path (fs ^. #files) of
           Just content -> pure content
           Nothing -> error ("runFilesystemPure: file not found: " <> path)
       WriteFileText path content -> do
-        modify @PureFS (\fs -> fs {files = Map.insert path content fs.files})
+        modify @PureFS (\fs -> fs & #files . at path ?~ content)
       CopyFile src dest -> do
         fs <- get @PureFS
-        case Map.lookup src fs.files of
+        case Map.lookup src (fs ^. #files) of
           Just content ->
-            put fs {files = Map.insert dest content fs.files}
+            put (fs & #files . at dest ?~ content)
           Nothing -> error ("runFilesystemPure: source file not found: " <> src)
       ListDirectory path -> do
         fs <- get @PureFS
         let prefix = if null path then "" else path <> "/"
             filesInDir =
               [ drop (length prefix) fp
-              | fp <- Map.keys fs.files,
+              | fp <- Map.keys (fs ^. #files),
                 isDirectChild prefix fp
               ]
             dirsInDir =
               [ drop (length prefix) d
-              | d <- Set.toList fs.dirs,
+              | d <- Set.toList (fs ^. #dirs),
                 isDirectChild prefix d
               ]
         pure (filesInDir <> dirsInDir)
       CreateDirectoryIfMissing _parents path -> do
-        modify @PureFS (\fs -> fs {dirs = Set.insert path fs.dirs})
+        modify @PureFS (\fs -> fs & #dirs %~ Set.insert path)
       DoesFileExist path -> do
         fs <- get @PureFS
-        pure (Map.member path fs.files)
+        pure (Map.member path (fs ^. #files))
       DoesDirectoryExist path -> do
         fs <- get @PureFS
-        pure (Set.member path fs.dirs)
+        pure (Set.member path (fs ^. #dirs))
       GetCurrentDirectory -> pure "/pure-fs"
       RemoveFile path -> do
-        modify @PureFS (\fs -> fs {files = Map.delete path fs.files})
+        modify @PureFS (\fs -> fs & #files . at path .~ Nothing)
       RemoveDirectoryIfEmpty path -> do
         fs <- get @PureFS
         let hasChildren =
-              any (\fp -> (path <> "/") `isPrefixOfPath` fp) (Map.keys fs.files)
-                || any (\d -> (path <> "/") `isPrefixOfPath` d) (Set.toList fs.dirs)
+              any (\fp -> (path <> "/") `isPrefixOfPath` fp) (Map.keys (fs ^. #files))
+                || any (\d -> (path <> "/") `isPrefixOfPath` d) (Set.toList (fs ^. #dirs))
         if hasChildren
           then pure ()
-          else modify @PureFS (\fs' -> fs' {dirs = Set.delete path fs'.dirs})
+          else modify @PureFS (\fs' -> fs' & #dirs %~ Set.delete path)
       RenamePath src dest -> do
         modify @PureFS (renameInPureFS src dest)
       RemoveDirectoryRecursive path -> do
@@ -106,9 +107,13 @@
 -- when callers have already validated existence).
 renameInPureFS :: FilePath -> FilePath -> PureFS -> PureFS
 renameInPureFS src dest fs =
-  let renamedFiles = Map.mapKeys (renameKey src dest) fs.files
-      renamedDirs = Set.map (renameKey src dest) fs.dirs
-   in fs {files = renamedFiles, dirs = renamedDirs}
+  let renamedFiles = Map.mapKeys (renameKey src dest) (fs ^. #files)
+      renamedDirs = Set.map (renameKey src dest) (fs ^. #dirs)
+   in fs
+        & #files
+        .~ renamedFiles
+        & #dirs
+        .~ renamedDirs
   where
     renameKey s d k
       | k == s = d
@@ -123,6 +128,7 @@
       keepFile k = k /= path && not (prefix `isPrefixOfPath` k)
       keepDir d = d /= path && not (prefix `isPrefixOfPath` d)
    in fs
-        { files = Map.filterWithKey (\k _ -> keepFile k) fs.files,
-          dirs = Set.filter keepDir fs.dirs
-        }
+        & #files
+        %~ Map.filterWithKey (\k _ -> keepFile k)
+        & #dirs
+        %~ Set.filter keepDir
diff --git a/src/Seihou/Effect/LoggerPure.hs b/src/Seihou/Effect/LoggerPure.hs
--- a/src/Seihou/Effect/LoggerPure.hs
+++ b/src/Seihou/Effect/LoggerPure.hs
@@ -5,6 +5,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Effectful.State.Static.Local (State, modify, runState)
 import Seihou.Effect.Logger (Logger (..))
 import Seihou.Prelude
@@ -12,12 +13,12 @@
 -- | State capturing all log messages by severity.
 -- Messages are appended in order within each field.
 data LoggerState = LoggerState
-  { logDebugMsgs :: [Text],
-    logInfoMsgs :: [Text],
-    logWarnMsgs :: [Text],
-    logErrorMsgs :: [Text]
+  { debugMsgs :: ![Text],
+    infoMsgs :: ![Text],
+    warnMsgs :: ![Text],
+    errorMsgs :: ![Text]
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Empty logger state with no captured messages.
 emptyLoggerState :: LoggerState
@@ -31,7 +32,7 @@
   where
     handler :: (State LoggerState :> es') => EffectHandler Logger es'
     handler _ = \case
-      LogDebug msg -> modify @LoggerState (\s -> s {logDebugMsgs = s.logDebugMsgs ++ [msg]})
-      LogInfo msg -> modify @LoggerState (\s -> s {logInfoMsgs = s.logInfoMsgs ++ [msg]})
-      LogWarn msg -> modify @LoggerState (\s -> s {logWarnMsgs = s.logWarnMsgs ++ [msg]})
-      LogError msg -> modify @LoggerState (\s -> s {logErrorMsgs = s.logErrorMsgs ++ [msg]})
+      LogDebug msg -> modify @LoggerState (\s -> s & #debugMsgs %~ (<> [msg]))
+      LogInfo msg -> modify @LoggerState (\s -> s & #infoMsgs %~ (<> [msg]))
+      LogWarn msg -> modify @LoggerState (\s -> s & #warnMsgs %~ (<> [msg]))
+      LogError msg -> modify @LoggerState (\s -> s & #errorMsgs %~ (<> [msg]))
diff --git a/src/Seihou/Effect/ProcessInterp.hs b/src/Seihou/Effect/ProcessInterp.hs
--- a/src/Seihou/Effect/ProcessInterp.hs
+++ b/src/Seihou/Effect/ProcessInterp.hs
@@ -12,6 +12,8 @@
 runProcessIO :: (IOE :> es) => Eff (Process : es) a -> Eff es a
 runProcessIO = interpret $ \_ -> \case
   RunProcess cmd args workDir -> liftIO $ do
+    -- CreateProcess is a third-party type with no Generic instance, so it has
+    -- no #cwd label to set. Record update syntax is the only option here.
     let cp =
           (proc (T.unpack cmd) (map T.unpack args))
             { cwd = workDir
diff --git a/src/Seihou/Effect/ProcessPure.hs b/src/Seihou/Effect/ProcessPure.hs
--- a/src/Seihou/Effect/ProcessPure.hs
+++ b/src/Seihou/Effect/ProcessPure.hs
@@ -4,16 +4,17 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Seihou.Effect.Process (Process (..))
 import Seihou.Prelude
 import System.Exit (ExitCode (..))
 
 data ProcessMock = ProcessMock
-  { mockCommand :: Text,
-    mockArgs :: [Text],
-    mockResult :: (ExitCode, Text, Text)
+  { command :: !Text,
+    args :: ![Text],
+    result :: !(ExitCode, Text, Text)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 runProcessPure :: [ProcessMock] -> Eff (Process : es) a -> Eff es a
 runProcessPure mocks = interpret $ \_ -> \case
@@ -25,5 +26,5 @@
 findMock :: Text -> [Text] -> [ProcessMock] -> Maybe (ExitCode, Text, Text)
 findMock _ _ [] = Nothing
 findMock cmd args (m : ms)
-  | m.mockCommand == cmd && m.mockArgs == args = Just m.mockResult
+  | m ^. #command == cmd && m ^. #args == args = Just (m ^. #result)
   | otherwise = findMock cmd args ms
diff --git a/src/Seihou/Engine/Baseline.hs b/src/Seihou/Engine/Baseline.hs
--- a/src/Seihou/Engine/Baseline.hs
+++ b/src/Seihou/Engine/Baseline.hs
@@ -5,6 +5,7 @@
 where
 
 import Control.Monad (foldM)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Maybe (mapMaybe)
 import Data.Set qualified as Set
@@ -41,14 +42,16 @@
           content <- readFileText fullPath
           ref <- putBaseline content
           let enriched =
-                record
-                  { hash = hashContent content,
-                    baseline = Just ref
-                  }
+                ( record
+                    & #hash
+                    .~ hashContent content
+                    & #baseline
+                    ?~ ref
+                )
           pure (Right (Map.insert path enriched captured))
 
 -- | Every blob protected by the currently durable manifest. Callers pass this
 -- set to 'pruneBaselines' only after publishing that manifest.
 manifestBaselineRefs :: Manifest -> Set BaselineRef
 manifestBaselineRefs manifest =
-  Set.fromList (mapMaybe (.baseline) (Map.elems manifest.files))
+  Set.fromList (mapMaybe (^. #baseline) (Map.elems (manifest ^. #files)))
diff --git a/src/Seihou/Engine/Conflict.hs b/src/Seihou/Engine/Conflict.hs
--- a/src/Seihou/Engine/Conflict.hs
+++ b/src/Seihou/Engine/Conflict.hs
@@ -4,6 +4,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.Types (ConflictFile (..), ConflictResolution (..))
 import Seihou.Effect.Console (Console, getLine, isInteractive, putText)
@@ -61,7 +62,7 @@
   ConflictFile ->
   Eff es ConflictResolution
 promptConflict c = do
-  putText $ "Conflict: " <> T.pack c.path <> " (modified since last generation)"
+  putText $ "Conflict: " <> T.pack (c ^. #path) <> " (modified since last generation)"
   promptChoice
   where
     promptChoice = do
diff --git a/src/Seihou/Engine/Diff.hs b/src/Seihou/Engine/Diff.hs
--- a/src/Seihou/Engine/Diff.hs
+++ b/src/Seihou/Engine/Diff.hs
@@ -4,6 +4,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set (Set)
 import Data.Set qualified as Set
@@ -38,9 +39,9 @@
   [(FilePath, Text, ModuleName, Maybe PatchOp)] ->
   Eff es DiffResult
 computeDiff manifest activeModules planned = do
-  let manifestFiles' = manifest.files
+  let manifestFiles' = (manifest ^. #files)
       activeManifestFiles =
-        Map.filter (\r -> r.moduleName `Set.member` activeModules) manifestFiles'
+        Map.filter (\r -> (r ^. #moduleName) `Set.member` activeModules) manifestFiles'
       planMap = Map.fromList [(p, (content, modName, patchOp)) | (p, content, modName, patchOp) <- planned]
       allPaths =
         Set.toList $
@@ -112,7 +113,7 @@
     (Just record, Just (content, modName, _), True) -> do
       diskContent <- readFileText path
       let diskHash = hashContent diskContent
-          manifestHash = record.hash
+          manifestHash = (record ^. #hash)
           planHash = hashContent content
       if diskHash /= manifestHash
         then
@@ -159,16 +160,16 @@
           ( ModifiedFile
               { path = path,
                 moduleName = modName,
-                oldHash = record.hash,
+                oldHash = record ^. #hash,
                 newContent = content
               }
           )
     -- In manifest, not in plan, on disk → Orphaned
     (Just record, Nothing, True) ->
-      pure $ ClassOrphaned (OrphanedFile {path = path, moduleName = record.moduleName})
+      pure $ ClassOrphaned (OrphanedFile {path = path, moduleName = record ^. #moduleName})
     -- In manifest, not in plan, not on disk → Orphaned (already deleted)
     (Just record, Nothing, False) ->
-      pure $ ClassOrphaned (OrphanedFile {path = path, moduleName = record.moduleName})
+      pure $ ClassOrphaned (OrphanedFile {path = path, moduleName = record ^. #moduleName})
     -- Not in manifest, not in plan → shouldn't happen (we only iterate known paths)
     (Nothing, Nothing, _) ->
       pure $ ClassUnchanged path -- unreachable in practice
diff --git a/src/Seihou/Engine/Execute.hs b/src/Seihou/Engine/Execute.hs
--- a/src/Seihou/Engine/Execute.hs
+++ b/src/Seihou/Engine/Execute.hs
@@ -4,6 +4,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Data.Time (UTCTime)
@@ -113,7 +114,7 @@
     formatOp (CopyFileOp src dest) = "  copy  " <> T.pack src <> " -> " <> T.pack dest
     formatOp RunCommandOp {command = cmd} = "  run   " <> cmd
     formatOp (PatchFileOp dest _ patchOp' _ modName) =
-      "  patch " <> T.pack dest <> " (" <> formatPatchOp patchOp' <> " from " <> modName.unModuleName <> ")"
+      "  patch " <> T.pack dest <> " (" <> formatPatchOp patchOp' <> " from " <> modName ^. #unModuleName <> ")"
     formatPatchOp AppendFile = "append-file"
     formatPatchOp PrependFile = "prepend-file"
     formatPatchOp AppendSection = "append-section"
diff --git a/src/Seihou/Engine/Migrate.hs b/src/Seihou/Engine/Migrate.hs
--- a/src/Seihou/Engine/Migrate.hs
+++ b/src/Seihou/Engine/Migrate.hs
@@ -11,12 +11,12 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.List (nub, sortBy)
 import Data.Map.Strict qualified as Map
 import Data.Ord (Down (..))
 import Data.Text qualified as T
 import Data.Time (UTCTime)
-import GHC.Generics (Generic)
 import Seihou.Core.Migration
   ( Migration (..),
     MigrationOp (..),
@@ -78,9 +78,9 @@
 -- 'MigrationPlan' it was built from, and the linearized list of
 -- concrete op instances in execution order.
 data ExecutedMigrationPlan = ExecutedMigrationPlan
-  { planModule :: ModuleName,
-    planSource :: MigrationPlan,
-    planOps :: [MigrationOpInstance]
+  { module_ :: !ModuleName,
+    source :: !MigrationPlan,
+    ops :: ![MigrationOpInstance]
   }
   deriving stock (Eq, Show, Generic)
 
@@ -107,14 +107,14 @@
   MigrationPlan ->
   Eff es (Either MigrationExecError ExecutedMigrationPlan)
 classifyMigration manifest plan = do
-  opsResult <- traverse (classifyOp manifest) (concatMap (.ops) plan.planSteps)
+  opsResult <- traverse (classifyOp manifest) (concatMap (^. #ops) (plan ^. #steps))
   pure $ do
     ops <- sequence opsResult
     Right
       ExecutedMigrationPlan
-        { planModule = ModuleName plan.planModule,
-          planSource = plan,
-          planOps = ops
+        { module_ = ModuleName (plan ^. #module_),
+          source = plan,
+          ops = ops
         }
 
 -- | Classify a single 'MigrationOp' against the manifest and disk.
@@ -173,12 +173,12 @@
   exists <- doesFileExist path
   if not exists
     then pure MFGone
-    else case Map.lookup path (manifest.files :: Map FilePath FileRecord) of
+    else case Map.lookup path (manifest ^. #files :: Map FilePath FileRecord) of
       Nothing -> pure MFSafe
       Just rec -> do
         content <- readFileText path
         let diskHash = hashContent content
-        if diskHash == rec.hash
+        if diskHash == rec ^. #hash
           then pure MFSafe
           else pure MFConflict
 
@@ -191,9 +191,9 @@
 -- Otherwise runs every op in declaration order, rewrites the manifest's
 -- @files@ map to reflect new paths, bumps @genAt@ to the supplied
 -- timestamp, and updates the named 'AppliedModule''s @moduleVersion@ to
--- @planTo@ (the user's supplied target). When the source plan has an
+-- @to@ (the user's supplied target). When the source plan has an
 -- empty 'planSteps' list, no file ops run but the manifest still
--- advances to @planTo@ — this is the "pure version bump" path.
+-- advances to @to@ — this is the "pure version bump" path.
 executeMigration ::
   (Filesystem :> es, Process :> es) =>
   -- | If 'True', proceed even when files are 'MFConflict'. Mirrors the
@@ -207,22 +207,24 @@
 executeMigration force plan manifest now = do
   let conflicts =
         [ p
-        | inst <- plan.planOps,
+        | inst <- plan ^. #ops,
           (p, MFConflict) <- toFileStatus inst
         ]
   if not force && not (null conflicts)
     then pure (Left (MigrationConflict conflicts))
     else do
-      result <- runOps plan.planOps manifest []
+      result <- runOps (plan ^. #ops) manifest []
       case result of
         Left err -> pure (Left err)
         Right (man', removedDirs) -> do
           cleanupEmptyDirs removedDirs
           let bumped =
-                man'
-                  { genAt = now,
-                    modules = map (bumpVersion plan.planModule plan.planSource) man'.modules
-                  }
+                ( man'
+                    & #genAt
+                    .~ now
+                    & #modules
+                    %~ map (bumpVersion (plan ^. #module_) (plan ^. #source))
+                )
           pure (Right bumped)
 
 -- | Pull (path, status) pairs out of an op for conflict detection. Only
@@ -290,12 +292,12 @@
 -- map. If the key isn't present, the manifest is returned unchanged.
 renameInManifest :: FilePath -> FilePath -> Manifest -> Manifest
 renameInManifest src dest manifest =
-  case Map.lookup src manifest.files of
+  case Map.lookup src (manifest ^. #files) of
     Nothing -> manifest
     Just rec ->
       manifest
-        { files = Map.insert dest rec (Map.delete src manifest.files)
-        }
+        & #files
+        .~ Map.insert dest rec (Map.delete src (manifest ^. #files))
 
 -- | Rewrite every @files@ key whose path is @src@ or under @src/@ to
 -- replace the prefix with @dest@.
@@ -306,26 +308,26 @@
         | k == src = dest
         | prefix `isPrefixOfPath` k = dest <> "/" <> drop (length prefix) k
         | otherwise = k
-   in manifest {files = Map.mapKeys rewriteKey manifest.files}
+   in manifest & #files %~ Map.mapKeys rewriteKey
 
 -- | Drop a single file entry from the manifest.
 dropFromManifest :: FilePath -> Manifest -> Manifest
 dropFromManifest p manifest =
-  manifest {files = Map.delete p manifest.files}
+  manifest & #files . at p .~ Nothing
 
 -- | Drop every file entry whose path is @path@ or under @path/@.
 dropDirFromManifest :: FilePath -> Manifest -> Manifest
 dropDirFromManifest path manifest =
   let prefix = path <> "/"
       keep k = k /= path && not (prefix `isPrefixOfPath` k)
-   in manifest {files = Map.filterWithKey (\k _ -> keep k) manifest.files}
+   in manifest & #files %~ Map.filterWithKey (\k _ -> keep k)
 
 -- | Update the named applied module's @moduleVersion@ to the plan's
 -- target. Other applied modules are untouched.
 bumpVersion :: ModuleName -> MigrationPlan -> AppliedModule -> AppliedModule
 bumpVersion modName plan am
-  | am.name == modName =
-      am {moduleVersion = Just (renderVersion plan.planTo)}
+  | am ^. #name == modName =
+      am & #moduleVersion ?~ (renderVersion (plan ^. #to))
   | otherwise = am
 
 -- ----------------------------------------------------------------------------
diff --git a/src/Seihou/Engine/Plan.hs b/src/Seihou/Engine/Plan.hs
--- a/src/Seihou/Engine/Plan.hs
+++ b/src/Seihou/Engine/Plan.hs
@@ -7,6 +7,7 @@
 import Control.Exception (IOException, SomeException, catch, try)
 import Data.Aeson qualified as Aeson
 import Data.Aeson.Encode.Pretty qualified as AesonPretty
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -36,11 +37,11 @@
   Map VarName VarValue -> -- Resolved variable values
   IO (Either [Text] [Operation])
 compilePlan baseDir modul vars = do
-  let modName = modul.name
-  results <- mapM (compileStep baseDir modName vars) modul.steps
+  let modName = (modul ^. #name)
+  results <- mapM (compileStep baseDir modName vars) (modul ^. #steps)
   let (allErrors, allOps) = partitionResults results
   if null allErrors
-    then case compileCommands modName vars modul.commands of
+    then case compileCommands modName vars (modul ^. #commands) of
       Left cmdErrs -> pure (Left cmdErrs)
       Right cmdOps -> pure (Right (deduplicateDirs (concat allOps) ++ cmdOps))
     else pure (Left (concat allErrors))
@@ -71,17 +72,17 @@
                     }
              in (Map.insert key (occurrence + 1) counts, ops ++ [operation], errs)
 
-    shouldRun cmd = case cmd.condition of
+    shouldRun cmd = case cmd ^. #condition of
       Nothing -> True
       Just expr -> evalExpr vars expr
 
 -- | Compile a single command, interpolating placeholders in @run@ and @workDir@.
 compileOneCommand :: Map VarName VarValue -> Command -> Either [Text] (Text, Maybe FilePath)
 compileOneCommand vars cmd =
-  case renderCommand cmd.run vars of
+  case renderCommand (cmd ^. #run) vars of
     Left placeholderErrors -> Left (map formatPlaceholderError placeholderErrors)
     Right runText ->
-      case cmd.workDir of
+      case cmd ^. #workDir of
         Nothing -> Right (runText, Nothing)
         Just wd ->
           case renderCommand wd vars of
@@ -102,14 +103,14 @@
   IO (Either [Text] [Operation])
 compileStep baseDir modName vars step = do
   -- Evaluate the when condition
-  let shouldRun = case step.condition of
+  let shouldRun = case step ^. #condition of
         Nothing -> True
         Just expr -> evalExpr vars expr
   if not shouldRun
     then pure (Right [])
-    else case step.patch of
+    else case step ^. #patch of
       Just _ -> compilePatchStep baseDir vars modName step
-      Nothing -> case step.strategy of
+      Nothing -> case step ^. #strategy of
         Copy -> compileCopyStep baseDir vars step
         Template -> compileTemplateStep baseDir vars step
         DhallText -> compileDhallTextStep baseDir vars step
@@ -122,12 +123,12 @@
   Step ->
   IO (Either [Text] [Operation])
 compileCopyStep baseDir vars step = do
-  let srcPath = baseDir </> "files" </> step.src
+  let srcPath = baseDir </> "files" </> (step ^. #src)
   result <- tryReadFile srcPath
   case result of
     Left err -> pure (Left [err])
     Right content ->
-      case renderDestPath step.dest vars of
+      case renderDestPath (step ^. #dest) vars of
         Left placeholderErrors ->
           pure (Left (map formatPlaceholderError placeholderErrors))
         Right dest ->
@@ -140,7 +141,7 @@
   Step ->
   IO (Either [Text] [Operation])
 compileTemplateStep baseDir vars step = do
-  let srcPath = baseDir </> "files" </> step.src
+  let srcPath = baseDir </> "files" </> (step ^. #src)
   result <- tryReadFile srcPath
   case result of
     Left err -> pure (Left [err])
@@ -149,7 +150,7 @@
         Left placeholderErrors ->
           pure (Left (map formatPlaceholderError placeholderErrors))
         Right rendered ->
-          case renderDestPath step.dest vars of
+          case renderDestPath (step ^. #dest) vars of
             Left placeholderErrors ->
               pure (Left (map formatPlaceholderError placeholderErrors))
             Right dest ->
@@ -162,7 +163,7 @@
   Step ->
   IO (Either [Text] [Operation])
 compileDhallTextStep baseDir vars step = do
-  let srcPath = baseDir </> "files" </> step.src
+  let srcPath = baseDir </> "files" </> (step ^. #src)
   result <- tryReadFile srcPath
   case result of
     Left err -> pure (Left [err])
@@ -175,7 +176,7 @@
           case dhallResult of
             Left err -> pure (Left [err])
             Right evaluated ->
-              case renderDestPath step.dest vars of
+              case renderDestPath (step ^. #dest) vars of
                 Left placeholderErrors ->
                   pure (Left (map formatPlaceholderError placeholderErrors))
                 Right dest ->
@@ -189,7 +190,7 @@
   Step ->
   IO (Either [Text] [Operation])
 compileStructuredStep baseDir vars step = do
-  let srcPath = baseDir </> "files" </> step.src
+  let srcPath = baseDir </> "files" </> (step ^. #src)
   result <- tryReadFile srcPath
   case result of
     Left err -> pure (Left [err])
@@ -205,7 +206,7 @@
               case dhallExprToJSON dhallExpr of
                 Left err -> pure (Left [err])
                 Right jsonValue ->
-                  case renderDestPath step.dest vars of
+                  case renderDestPath (step ^. #dest) vars of
                     Left placeholderErrors ->
                       pure (Left (map formatPlaceholderError placeholderErrors))
                     Right dest ->
@@ -226,8 +227,8 @@
   Step ->
   IO (Either [Text] [Operation])
 compilePatchStep baseDir vars modName step = do
-  let srcPath = baseDir </> "files" </> step.src
-      patchOp' = case step.patch of
+  let srcPath = baseDir </> "files" </> (step ^. #src)
+      patchOp' = case step ^. #patch of
         Just p -> p
         Nothing -> error "compilePatchStep called without patch op"
   result <- tryReadFile srcPath
@@ -235,7 +236,7 @@
     Left err -> pure (Left [err])
     Right rawContent -> do
       -- Render content based on strategy
-      contentResult <- case step.strategy of
+      contentResult <- case step ^. #strategy of
         Copy -> pure (Right rawContent)
         Template ->
           pure $ case renderTemplateText rawContent vars of
@@ -254,11 +255,11 @@
       case contentResult of
         Left errs -> pure (Left errs)
         Right content ->
-          case renderDestPath step.dest vars of
+          case renderDestPath (step ^. #dest) vars of
             Left placeholderErrors ->
               pure (Left (map formatPlaceholderError placeholderErrors))
             Right dest ->
-              pure (patchFileOps dest content patchOp' step.strategy modName)
+              pure (patchFileOps dest content patchOp' (step ^. #strategy) modName)
 
 -- | Evaluate a Dhall expression and return the normalized AST.
 evaluateDhallExpr :: Text -> IO (Either Text (DhallCore.Expr Src Void))
diff --git a/src/Seihou/Engine/Preview.hs b/src/Seihou/Engine/Preview.hs
--- a/src/Seihou/Engine/Preview.hs
+++ b/src/Seihou/Engine/Preview.hs
@@ -7,6 +7,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -26,15 +27,15 @@
 -- | One line in the dry-run preview.
 data PreviewLine
   = FilePreview
-      { previewStatus :: FileStatus,
-        previewPath :: FilePath,
-        previewAnnotation :: Text,
-        previewModule :: Maybe ModuleName
+      { status :: !FileStatus,
+        path :: !FilePath,
+        annotation :: !Text,
+        module_ :: !(Maybe ModuleName)
       }
   | DirPreview FilePath
   | CommandPreview Text (Maybe ModuleName)
   | OrphanPreview FilePath ModuleName
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Build a structured preview from operations and an optional diff result.
 -- The ownership map tracks which module produced each file path.
@@ -56,9 +57,9 @@
         Just diff ->
           -- Only include orphans whose path is NOT produced by any operation
           let producedPaths = Set.fromList [p | op <- ops, Just p <- [destOfOp op]]
-           in [ OrphanPreview o.path o.moduleName
-              | o <- diff.orphaned,
-                not (Set.member o.path producedPaths)
+           in [ OrphanPreview (o ^. #path) (o ^. #moduleName)
+              | o <- diff ^. #orphaned,
+                not (Set.member (o ^. #path) producedPaths)
               ]
    in opLines ++ orphanLines
 
@@ -66,18 +67,18 @@
 opToPreview :: Maybe DiffResult -> Map FilePath ModuleName -> Set Text -> Operation -> PreviewLine
 opToPreview mDiff ownerMap _ (WriteFileOp dest _ strat) =
   FilePreview
-    { previewStatus = lookupStatus dest mDiff,
-      previewPath = dest,
-      previewAnnotation = strategyName strat,
-      previewModule = Map.lookup dest ownerMap
+    { status = lookupStatus dest mDiff,
+      path = dest,
+      annotation = strategyName strat,
+      module_ = Map.lookup dest ownerMap
     }
 opToPreview _ _ _ (CreateDirOp path) = DirPreview path
 opToPreview mDiff ownerMap _ (CopyFileOp _ dest) =
   FilePreview
-    { previewStatus = lookupStatus dest mDiff,
-      previewPath = dest,
-      previewAnnotation = "copy",
-      previewModule = Map.lookup dest ownerMap
+    { status = lookupStatus dest mDiff,
+      path = dest,
+      annotation = "copy",
+      module_ = Map.lookup dest ownerMap
     }
 opToPreview _ _ commandsNeedingOwner RunCommandOp {command, moduleName} =
   CommandPreview
@@ -85,21 +86,21 @@
     (if Set.member command commandsNeedingOwner then Just moduleName else Nothing)
 opToPreview mDiff ownerMap _ (PatchFileOp dest _ _patchOp' _ modName') =
   FilePreview
-    { previewStatus = lookupStatus dest mDiff,
-      previewPath = dest,
-      previewAnnotation = "patch",
-      previewModule = Just modName'
+    { status = lookupStatus dest mDiff,
+      path = dest,
+      annotation = "patch",
+      module_ = Just modName'
     }
 
 -- | Look up a file's status in the diff result.
 lookupStatus :: FilePath -> Maybe DiffResult -> FileStatus
 lookupStatus _ Nothing = FsNew
 lookupStatus path (Just diff)
-  | any (\f -> f.path == path) diff.new = FsNew
-  | any (\f -> f.path == path) diff.modified = FsModified
-  | path `elem` diff.unchanged = FsUnchanged
-  | any (\f -> f.path == path) diff.conflicts = FsConflict
-  | any (\f -> f.path == path) diff.orphaned = FsOrphaned
+  | any (\f -> f ^. #path == path) (diff ^. #new) = FsNew
+  | any (\f -> f ^. #path == path) (diff ^. #modified) = FsModified
+  | path `elem` (diff ^. #unchanged) = FsUnchanged
+  | any (\f -> f ^. #path == path) (diff ^. #conflicts) = FsConflict
+  | any (\f -> f ^. #path == path) (diff ^. #orphaned) = FsOrphaned
   | otherwise = FsUnknown
 
 -- | Render preview lines as plain text (no ANSI codes).
@@ -111,14 +112,17 @@
   where
     fileLines = [l | l@(FilePreview {}) <- lines']
     nonFileLines = [l | l <- lines', not (isFileLine l)]
-    maxPathLen = maximum (0 : map (T.length . T.pack . (.previewPath)) fileLines)
+    -- PreviewLine is a sum type and `path` lives only in FilePreview, so this
+    -- is a pattern match rather than a #path read: generic-lens can only build
+    -- a lens for a field that every constructor has.
+    maxPathLen = maximum (0 : [T.length (T.pack p) | FilePreview {path = p} <- lines'])
 
 renderPlainLine :: Int -> PreviewLine -> Text
 renderPlainLine maxPath (FilePreview status path annotation mMod) =
   let pathText = T.pack path
       pathPad = T.replicate (maxPath - T.length pathText) " "
       modSuffix = case mMod of
-        Just mn -> ", " <> mn.unModuleName
+        Just mn -> ", " <> (mn ^. #unModuleName)
         Nothing -> ""
    in "    " <> statusTag status <> "  " <> pathText <> pathPad <> "  (" <> annotation <> modSuffix <> ")"
 renderPlainLine _ other = renderNonFileLine other
@@ -129,9 +133,9 @@
 renderNonFileLine (CommandPreview cmd mOwner) =
   "    run    " <> cmd <> ownerSuffix mOwner
   where
-    ownerSuffix = maybe "" (\owner -> "  (" <> owner.unModuleName <> ")")
+    ownerSuffix = maybe "" (\owner -> "  (" <> owner ^. #unModuleName <> ")")
 renderNonFileLine (OrphanPreview path modName') =
-  "    [orphaned]  " <> T.pack path <> "  (orphaned from " <> modName'.unModuleName <> ")"
+  "    [orphaned]  " <> T.pack path <> "  (orphaned from " <> modName' ^. #unModuleName <> ")"
 renderNonFileLine _ = ""
 
 isFileLine :: PreviewLine -> Bool
@@ -165,7 +169,7 @@
   where
     header =
       "Generation Plan ("
-        <> T.intercalate " + " (map (.unModuleName) moduleNames)
+        <> T.intercalate " + " (map (^. #unModuleName) moduleNames)
         <> "):"
 
     varsSection =
@@ -188,8 +192,8 @@
     showVarValue (VInt n) = T.pack (show n)
     showVarValue (VList vs) = "[" <> T.intercalate ", " (map showVarValue vs) <> "]"
 
-    nFiles = length diff.new + length diff.modified
-    nConflicts = length diff.conflicts
+    nFiles = length (diff ^. #new) + length (diff ^. #modified)
+    nConflicts = length (diff ^. #conflicts)
     summaryText =
       "  "
         <> T.pack (show nFiles)
diff --git a/src/Seihou/Engine/Reconcile.hs b/src/Seihou/Engine/Reconcile.hs
--- a/src/Seihou/Engine/Reconcile.hs
+++ b/src/Seihou/Engine/Reconcile.hs
@@ -23,6 +23,7 @@
 
 import Control.Monad (foldM)
 import Data.Foldable (traverse_)
+import Data.Generics.Labels ()
 import Data.List (foldl')
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
@@ -41,20 +42,20 @@
 -- The application set is path-specific: a batch may update several
 -- applications without every application contributing to every path.
 data DesiredFileOwner = DesiredFileOwner
-  { moduleName :: ModuleName,
-    applicationIds :: Set ApplicationId
+  { moduleName :: !ModuleName,
+    applicationIds :: !(Set ApplicationId)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | The final generated side after all operations for a path are replayed.
 data DesiredFile = DesiredFile
-  { path :: FilePath,
-    generatedContent :: Text,
-    moduleName :: ModuleName,
-    strategy :: Strategy,
-    applicationIds :: Set ApplicationId
+  { path :: !FilePath,
+    generatedContent :: !Text,
+    moduleName :: !ModuleName,
+    strategy :: !Strategy,
+    applicationIds :: !(Set ApplicationId)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 data ReconciliationReason
   = MissingTrustedBaseline
@@ -66,28 +67,28 @@
 -- | The disk snapshot used while planning. Applying verifies every snapshot
 -- before the first mutation, so a resolution cannot overwrite later edits.
 data ObservedFile = ObservedFile
-  { existed :: Bool,
-    contentHash :: Maybe SHA256
+  { existed :: !Bool,
+    contentHash :: !(Maybe SHA256)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | The exact generated ancestor and applied bytes a resolved action will
 -- publish. @writeToDisk@ is false for paths already containing those bytes.
 -- @recordedHash@ may intentionally remain the prior applied hash for a
 -- user-only edit that generation did not change.
 data PlannedFileState = PlannedFileState
-  { generatedBaseline :: Text,
-    appliedContent :: Text,
-    recordedHash :: SHA256,
-    writeToDisk :: Bool
+  { generatedBaseline :: !Text,
+    appliedContent :: !Text,
+    recordedHash :: !SHA256,
+    writeToDisk :: !Bool
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 data ResolvedFileConflict = ResolvedFileConflict
-  { choice :: FileConflictChoice,
-    state :: PlannedFileState
+  { choice :: !FileConflictChoice,
+    state :: !PlannedFileState
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 data FileReconciliation
   = FileCreate DesiredFile PlannedFileState ObservedFile
@@ -109,11 +110,11 @@
   deriving stock (Eq, Show)
 
 data ReconciliationPlan = ReconciliationPlan
-  { applicationIds :: Set ApplicationId,
-    files :: Map FilePath FileReconciliation,
-    requiredDirectories :: Set FilePath
+  { applicationIds :: !(Set ApplicationId),
+    files :: !(Map FilePath FileReconciliation),
+    requiredDirectories :: !(Set FilePath)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 data ReconciliationError
   = InvalidReconciliationPath FilePath Text
@@ -143,16 +144,16 @@
   deriving stock (Eq, Show)
 
 data ReconciliationSummary = ReconciliationSummary
-  { creates :: Int,
-    updates :: Int,
-    merged :: Int,
-    unchanged :: Int,
-    conflicts :: Int,
-    safeDeletes :: Int,
-    editedOrphans :: Int,
-    sharedOwnership :: Int
+  { creates :: !Int,
+    updates :: !Int,
+    merged :: !Int,
+    unchanged :: !Int,
+    conflicts :: !Int,
+    safeDeletes :: !Int,
+    editedOrphans :: !Int,
+    sharedOwnership :: !Int
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Production planner using the repository filesystem and baseline effects,
 -- with EP-65's Git-backed merge driver for dual edits.
@@ -231,13 +232,14 @@
                 }
 
 data DesiredContext = DesiredContext
-  { desired :: DesiredFile,
-    current :: Maybe Text,
-    baseline :: Maybe Text,
-    priorRecord :: Maybe FileRecord,
-    observed :: ObservedFile,
-    missingTrustedBaseline :: Bool
+  { desired :: !DesiredFile,
+    current :: !(Maybe Text),
+    baseline :: !(Maybe Text),
+    priorRecord :: !(Maybe FileRecord),
+    observed :: !ObservedFile,
+    missingTrustedBaseline :: !Bool
   }
+  deriving stock (Generic)
 
 validateInputs ::
   Set ApplicationId ->
@@ -262,15 +264,15 @@
 validateOwner selected ownerMap manifest path = case Map.lookup path ownerMap of
   Nothing -> Left (MissingDesiredOwner path)
   Just owner
-    | not (owner.applicationIds `Set.isSubsetOf` selected) ->
-        Left (DesiredOwnerOutsideSelection path (owner.applicationIds Set.\\ selected))
-    | otherwise -> case Map.lookup path manifest.files of
+    | not ((owner ^. #applicationIds) `Set.isSubsetOf` selected) ->
+        Left (DesiredOwnerOutsideSelection path ((owner ^. #applicationIds) Set.\\ selected))
+    | otherwise -> case Map.lookup path (manifest ^. #files) of
         Nothing -> Right ()
         Just record ->
-          let unselectedOwners = record.applicationIds Set.\\ selected
+          let unselectedOwners = (record ^. #applicationIds) Set.\\ selected
            in if Set.null unselectedOwners
                 then Right ()
-                else Left (SharedPathRequiresApplications path record.applicationIds)
+                else Left (SharedPathRequiresApplications path (record ^. #applicationIds))
 
 validateManagedPath :: FilePath -> Either ReconciliationError ()
 validateManagedPath rawPath = case validateProjectRelativePath (T.pack rawPath) of
@@ -316,7 +318,7 @@
   m (Either ReconciliationError DesiredContext)
 materializeOne readDisk readCopy readStoredBaseline ownerMap manifest pathOperations = do
   let path = operationPath pathOperations
-      prior = Map.lookup path manifest.files
+      prior = Map.lookup path (manifest ^. #files)
       owner = ownerMap Map.! path
       containsReplacement = any isReplacement pathOperations
   current <- readDisk path
@@ -351,9 +353,9 @@
           DesiredFile
             { path = path,
               generatedContent = generated,
-              moduleName = owner.moduleName,
+              moduleName = owner ^. #moduleName,
               strategy = finalStrategy,
-              applicationIds = owner.applicationIds
+              applicationIds = owner ^. #applicationIds
             }
     Right
       DesiredContext
@@ -378,13 +380,13 @@
   FileRecord ->
   Maybe Text ->
   m BaselineTrust
-trustedBaseline readStored record current = case record.baseline of
+trustedBaseline readStored record current = case record ^. #baseline of
   Just ref -> do
     result <- readStored ref
     pure (either (const Untrusted) (\content -> Trusted content False) result)
   Nothing ->
     pure $ case current of
-      Just content | hashContent content == record.hash -> Trusted content True
+      Just content | hashContent content == record ^. #hash -> Trusted content True
       _ -> Untrusted
 
 applyGenerationOperation ::
@@ -419,20 +421,20 @@
   (Text -> Text -> Text -> m MergeOutcome) ->
   DesiredContext ->
   m (Either ReconciliationError (FilePath, FileReconciliation))
-classifyDesired mergeContents context = case context.current of
+classifyDesired mergeContents context = case context ^. #current of
   Nothing -> pure $ Right (path, classifyMissing)
   Just current
-    | context.missingTrustedBaseline ->
+    | context ^. #missingTrustedBaseline ->
         pure $ Right (path, unresolved current current MissingTrustedBaseline)
-    | otherwise -> case context.baseline of
+    | otherwise -> case context ^. #baseline of
         Nothing -> pure $ Right (path, unresolved current current MissingTrustedBaseline)
         Just baseline -> classifyPresent baseline current
   where
-    desired = context.desired
-    path = desired.path
-    generated = desired.generatedContent
-    prior = context.priorRecord
-    observed = context.observed
+    desired = (context ^. #desired)
+    path = (desired ^. #path)
+    generated = (desired ^. #generatedContent)
+    prior = (context ^. #priorRecord)
+    observed = (context ^. #observed)
 
     classifyMissing = case prior of
       Nothing -> FileCreate desired (automaticState generated True) observed
@@ -444,7 +446,7 @@
       | current == baseline =
           pure (Right (path, FileUpdate desired (automaticState generated True) observed prior))
       | generated == baseline =
-          let priorHash = maybe (hashContent current) (.hash) prior
+          let priorHash = maybe (hashContent current) (^. #hash) prior
               state = PlannedFileState generated current priorHash False
            in pure (Right (path, FileUnchanged desired state observed prior))
       | current == generated =
@@ -456,7 +458,7 @@
     fromMerge _ (MergeClean merged) =
       FileAutoMerge
         desired
-        (PlannedFileState generated merged (hashContent merged) (context.current /= Just merged))
+        (PlannedFileState generated merged (hashContent merged) (context ^. #current /= Just merged))
         observed
         prior
     fromMerge current (MergeConflicted markers) = unresolved current markers OverlappingEdits
@@ -482,20 +484,20 @@
   where
     candidates =
       [ (path, record)
-      | (path, record) <- Map.toList manifest.files,
-        Set.null (Set.intersection selected record.applicationIds) == False,
+      | (path, record) <- Map.toList (manifest ^. #files),
+        Set.null (Set.intersection selected (record ^. #applicationIds)) == False,
         Set.notMember path desiredPaths
       ]
     classify (path, record) = do
       current <- readDisk path
       let observed = observe current
-          remainingOwners = record.applicationIds Set.\\ selected
+          remainingOwners = (record ^. #applicationIds) Set.\\ selected
           action
             | not (Set.null remainingOwners) = FileReleaseSharedOwnership path record observed
             | otherwise = case current of
                 Nothing -> FileAlreadyAbsent path record observed
                 Just content
-                  | hashContent content == record.hash -> FileDeleteSafe path record observed
+                  | hashContent content == record ^. #hash -> FileDeleteSafe path record observed
                   | otherwise -> FileOrphanEdited path record content observed Nothing
       pure (path, action)
 
@@ -507,24 +509,24 @@
   FileConflictChoice ->
   ReconciliationPlan ->
   Either ReconciliationError ReconciliationPlan
-resolveFileConflict path choice plan = case Map.lookup path plan.files of
+resolveFileConflict path choice plan = case Map.lookup path (plan ^. #files) of
   Nothing -> Left (ReconciliationPathNotFound path)
   Just (FileConflict _ _ _ _ _ _ _) | choice == AbortUpdate -> Left (UpdateAborted path)
   Just (FileConflict desired current markers reason observed prior _) ->
     let applied = case choice of
-          AcceptGenerated -> desired.generatedContent
+          AcceptGenerated -> (desired ^. #generatedContent)
           KeepCurrent -> current
           WriteConflictMarkers -> markers
           AbortUpdate -> current
         state =
           PlannedFileState
-            { generatedBaseline = desired.generatedContent,
+            { generatedBaseline = desired ^. #generatedContent,
               appliedContent = applied,
               recordedHash = hashContent applied,
-              writeToDisk = applied /= current || not observed.existed
+              writeToDisk = applied /= current || not (observed ^. #existed)
             }
         resolved = FileConflict desired current markers reason observed prior (Just (ResolvedFileConflict choice state))
-     in Right (replacePlanFiles plan (Map.insert path resolved plan.files))
+     in Right (replacePlanFiles plan (Map.insert path resolved (plan ^. #files)))
   Just _ -> Left (NotAFileConflict path)
 
 resolveEditedOrphan ::
@@ -532,7 +534,7 @@
   OrphanChoice ->
   ReconciliationPlan ->
   Either ReconciliationError ReconciliationPlan
-resolveEditedOrphan path choice plan = case Map.lookup path plan.files of
+resolveEditedOrphan path choice plan = case Map.lookup path (plan ^. #files) of
   Nothing -> Left (ReconciliationPathNotFound path)
   Just (FileOrphanEdited _ _ _ _ _) | choice == AbortOrphanUpdate -> Left (UpdateAborted path)
   Just (FileOrphanEdited orphanPath record content observed _) ->
@@ -542,12 +544,12 @@
         ( Map.insert
             path
             (FileOrphanEdited orphanPath record content observed (Just choice))
-            plan.files
+            (plan ^. #files)
         )
   Just _ -> Left (NotAnEditedOrphan path)
 
 reconciliationSummary :: ReconciliationPlan -> ReconciliationSummary
-reconciliationSummary = foldl' count emptySummary . Map.elems . (.files)
+reconciliationSummary = foldl' count emptySummary . Map.elems . (^. #files)
   where
     emptySummary = ReconciliationSummary 0 0 0 0 0 0 0 0
     count summary reconciliation = case reconciliation of
@@ -556,7 +558,7 @@
       FileAutoMerge _ _ _ _ -> addMerge summary
       FileUnchanged _ _ _ _ -> addUnchanged summary
       FileConflict _ _ _ _ _ _ Nothing -> addConflict summary
-      FileConflict _ _ _ _ _ _ (Just resolved) -> case resolved.choice of
+      FileConflict _ _ _ _ _ _ (Just resolved) -> case resolved ^. #choice of
         AcceptGenerated -> addUpdate summary
         KeepCurrent -> addMerge summary
         WriteConflictMarkers -> addMerge summary
@@ -578,16 +580,16 @@
 replacePlanFiles :: ReconciliationPlan -> Map FilePath FileReconciliation -> ReconciliationPlan
 replacePlanFiles plan newFiles =
   ReconciliationPlan
-    { applicationIds = plan.applicationIds,
+    { applicationIds = plan ^. #applicationIds,
       files = newFiles,
-      requiredDirectories = plan.requiredDirectories
+      requiredDirectories = plan ^. #requiredDirectories
     }
 
 reconciliationMutationPaths :: ReconciliationPlan -> Set FilePath
-reconciliationMutationPaths = Map.keysSet . (.files)
+reconciliationMutationPaths = Map.keysSet . (^. #files)
 
 unresolvedPaths :: ReconciliationPlan -> Set FilePath
-unresolvedPaths plan = Map.keysSet (Map.filter unresolved plan.files)
+unresolvedPaths plan = Map.keysSet (Map.filter unresolved (plan ^. #files))
   where
     unresolved (FileConflict _ _ _ _ _ _ Nothing) = True
     unresolved (FileOrphanEdited _ _ _ _ Nothing) = True
diff --git a/src/Seihou/Engine/Remove.hs b/src/Seihou/Engine/Remove.hs
--- a/src/Seihou/Engine/Remove.hs
+++ b/src/Seihou/Engine/Remove.hs
@@ -13,6 +13,7 @@
 where
 
 import Control.Monad (foldM)
+import Data.Generics.Labels ()
 import Data.List (nub, sortBy)
 import Data.Map.Strict qualified as Map
 import Data.Ord (Down (..))
@@ -44,10 +45,10 @@
 
 -- | A plan describing what files to remove for a given module.
 data RemovalPlan = RemovalPlan
-  { targetModule :: ModuleName,
-    files :: [RemovalFile]
+  { targetModule :: !ModuleName,
+    files :: ![RemovalFile]
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- ============================================================
 -- New step-based removal types
@@ -77,10 +78,10 @@
 
 -- | A removal plan built from declared removal steps.
 data ExecutedRemovalPlan = ExecutedRemovalPlan
-  { targetModule :: ModuleName,
-    ops :: [RemovalOp]
+  { targetModule :: !ModuleName,
+    ops :: ![RemovalOp]
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Errors that prevent removal.
 data RemovalError
@@ -108,7 +109,7 @@
   case findApplied manifest modName of
     Nothing -> pure (Left (ModuleNotApplied modName))
     Just am
-      | Nothing <- am.removal -> pure (Left (ModuleNotRemovable modName))
+      | Nothing <- am ^. #removal -> pure (Left (ModuleNotRemovable modName))
       | otherwise -> do
           let ownedFiles = moduleFiles manifest modName
           classified <- mapM classifyForRemoval ownedFiles
@@ -127,7 +128,7 @@
   let toDelete = filesToDelete plan keepSet
   mapM_ removeFile toDelete
   cleanupEmptyDirs toDelete
-  pure (removeFromManifest manifest plan.targetModule now)
+  pure (removeFromManifest manifest (plan ^. #targetModule) now)
 
 -- ============================================================
 -- New step-based removal engine
@@ -145,8 +146,8 @@
   case findApplied manifest modName of
     Nothing -> pure (Left (ModuleNotApplied modName))
     Just _ -> do
-      stepResults <- mapM (buildStepOp manifest modName) removal.removalSteps
-      let cmdResults = map buildCommandOp removal.removalCommands
+      stepResults <- mapM (buildStepOp manifest modName) (removal ^. #steps)
+      let cmdResults = map buildCommandOp (removal ^. #commands)
       pure $ do
         stepOps <- sequence stepResults
         cmdOps <- sequence cmdResults
@@ -163,31 +164,31 @@
   ModuleName ->
   RemovalStep ->
   Eff es (Either RemovalError RemovalOp)
-buildStepOp manifest _modName step = case step.action of
+buildStepOp manifest _modName step = case step ^. #action of
   RemoveFileAction ->
-    case validateRemovalPath "remove-file destination" step.dest of
+    case validateRemovalPath "remove-file destination" (step ^. #dest) of
       Left err -> pure (Left err)
       Right path -> do
         status <- classifyFileStatus manifest path
         pure (Right (DeleteFileOp path status))
   RemoveSectionAction ->
     pure $
-      case validateRemovalPath "remove-section destination" step.dest of
+      case validateRemovalPath "remove-section destination" (step ^. #dest) of
         Left err -> Left err
         Right path -> Right (StripSectionOp path)
   RewriteFileAction ->
     pure $ do
-      dest <- validateRemovalPath "rewrite-file destination" step.dest
-      src <- case step.src of
+      dest <- validateRemovalPath "rewrite-file destination" (step ^. #dest)
+      src <- case step ^. #src of
         Just s -> validateRemovalPath "rewrite-file source" (T.pack s)
         Nothing -> Left (RemovalUnsafePath "rewrite-file source" "" "path must not be empty")
       Right (RewriteOp dest src)
 
 buildCommandOp :: Command -> Either RemovalError RemovalOp
 buildCommandOp command =
-  case traverse (validateRemovalPath "remove-command workDir") command.workDir of
+  case traverse (validateRemovalPath "remove-command workDir") (command ^. #workDir) of
     Left err -> Left err
-    Right safeWorkDir -> Right (RemovalCommandOp command.run (fmap T.pack safeWorkDir))
+    Right safeWorkDir -> Right (RemovalCommandOp (command ^. #run) (fmap T.pack safeWorkDir))
 
 validateRemovalPath :: Text -> Text -> Either RemovalError FilePath
 validateRemovalPath label path =
@@ -205,12 +206,12 @@
   exists <- doesFileExist path
   if not exists
     then pure RFGone
-    else case Map.lookup path manifest.files of
+    else case Map.lookup path (manifest ^. #files) of
       Nothing -> pure RFSafe -- Not in manifest, treat as safe to delete
       Just rec -> do
         content <- readFileText path
         let diskHash = hashContent content
-        if diskHash == rec.hash
+        if diskHash == rec ^. #hash
           then pure RFSafe
           else pure RFConflict
 
@@ -223,8 +224,8 @@
   UTCTime ->
   Eff es Manifest
 executeRemovalOps manifest plan keepSet now = do
-  let modName = plan.targetModule
-  deletedPaths <- foldM (execOp modName keepSet) [] plan.ops
+  let modName = (plan ^. #targetModule)
+  deletedPaths <- foldM (execOp modName keepSet) [] (plan ^. #ops)
   cleanupEmptyDirs deletedPaths
   pure (removeFromManifest manifest modName now)
 
@@ -283,7 +284,7 @@
 -- | Find an applied module by name.
 findApplied :: Manifest -> ModuleName -> Maybe AppliedModule
 findApplied manifest modName =
-  case filter (\am -> am.name == modName) manifest.modules of
+  case filter (\am -> am ^. #name == modName) (manifest ^. #modules) of
     (am : _) -> Just am
     [] -> Nothing
 
@@ -291,8 +292,8 @@
 moduleFiles :: Manifest -> ModuleName -> [(FilePath, FileRecord)]
 moduleFiles manifest modName =
   [ (path, rec)
-  | (path, rec) <- Map.toList manifest.files,
-    rec.moduleName == modName
+  | (path, rec) <- Map.toList (manifest ^. #files),
+    rec ^. #moduleName == modName
   ]
 
 -- | Classify a single file for removal (legacy).
@@ -304,7 +305,7 @@
     else do
       content <- readFileText path
       let diskHash = hashContent content
-      if diskHash == rec.hash
+      if diskHash == rec ^. #hash
         then pure (RemovalSafe path)
         else pure (RemovalConflict path)
 
@@ -312,7 +313,7 @@
 filesToDelete :: RemovalPlan -> Set FilePath -> [FilePath]
 filesToDelete plan keepSet =
   [ path
-  | rf <- plan.files,
+  | rf <- plan ^. #files,
     let path = removalFilePath rf,
     shouldDelete rf,
     not (Set.member path keepSet)
@@ -350,7 +351,9 @@
 removeFromManifest :: Manifest -> ModuleName -> UTCTime -> Manifest
 removeFromManifest manifest modName now =
   manifest
-    { modules = filter (\am -> am.name /= modName) manifest.modules,
-      files = Map.filter (\rec -> rec.moduleName /= modName) manifest.files,
-      genAt = now
-    }
+    & #modules
+    %~ filter (\am -> am ^. #name /= modName)
+    & #files
+    %~ Map.filter (\rec -> rec ^. #moduleName /= modName)
+    & #genAt
+    .~ now
diff --git a/src/Seihou/Engine/Section.hs b/src/Seihou/Engine/Section.hs
--- a/src/Seihou/Engine/Section.hs
+++ b/src/Seihou/Engine/Section.hs
@@ -8,28 +8,29 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.Types
 import Seihou.Prelude
 
 -- | A section marker identifies content contributed by a module.
 data SectionMarker = SectionMarker
-  { sectionPrefix :: Text,
-    sectionModule :: ModuleName
+  { prefix :: !Text,
+    module_ :: !ModuleName
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Render an opening section marker line.
 -- Result: @"# --- seihou:haskell-base ---\\n"@
 renderSectionOpen :: SectionMarker -> Text
 renderSectionOpen marker =
-  marker.sectionPrefix <> " --- seihou:" <> marker.sectionModule.unModuleName <> " ---\n"
+  marker ^. #prefix <> " --- seihou:" <> marker ^. #module_ . #unModuleName <> " ---\n"
 
 -- | Render a closing section marker line.
 -- Result: @"# --- /seihou:haskell-base ---\\n"@
 renderSectionClose :: SectionMarker -> Text
 renderSectionClose marker =
-  marker.sectionPrefix <> " --- /seihou:" <> marker.sectionModule.unModuleName <> " ---\n"
+  marker ^. #prefix <> " --- /seihou:" <> marker ^. #module_ . #unModuleName <> " ---\n"
 
 -- | Wrap content in section markers.
 wrapInSection :: SectionMarker -> Text -> Text
@@ -49,7 +50,7 @@
 -- unchanged. Cleans up resulting double blank lines.
 removeSection :: ModuleName -> Text -> Text -> Text
 removeSection modName prefix content =
-  let marker = SectionMarker {sectionPrefix = prefix, sectionModule = modName}
+  let marker = SectionMarker {prefix = prefix, module_ = modName}
       openTag = T.stripEnd (renderSectionOpen marker)
       closeTag = T.stripEnd (renderSectionClose marker)
       ls = T.lines content
@@ -85,7 +86,7 @@
 applyTextPatch PrependFile _ _ existing new =
   Right (ensureTrailingNewline new <> existing)
 applyTextPatch AppendSection modName prefix existing new =
-  let marker = SectionMarker {sectionPrefix = prefix, sectionModule = modName}
+  let marker = SectionMarker {prefix = prefix, module_ = modName}
    in Right (ensureTrailingNewline existing <> wrapInSection marker new)
 applyTextPatch AppendLineIfAbsent _ _ existing new =
   let existingLines = map T.stripEnd (T.lines existing)
diff --git a/src/Seihou/Engine/Template.hs b/src/Seihou/Engine/Template.hs
--- a/src/Seihou/Engine/Template.hs
+++ b/src/Seihou/Engine/Template.hs
@@ -198,14 +198,15 @@
     -- (i.e. the body plus whatever follows the matching @{{/if}}@);
     -- @expr@ is the raw expression text.
     FoundIf
-      { foundBefore :: Text,
-        foundAfter :: Text,
-        foundExpr :: Text
+      { before :: !Text,
+        after :: !Text,
+        expr :: !Text
       }
   | -- | A @{{#else}}@ or @{{/if}}@ encountered before any matching
     -- @{{#if}}@ at the current depth. The 'Int' is the line offset
     -- (0-based) from the start of the scanned region.
     FoundOrphan Text Int
+  deriving stock (Generic)
 
 -- | Scan @input@ for the next block token at the outer level (i.e. for
 -- the purpose of locating the next @{{#if}}@ opener, or an orphan if
@@ -225,9 +226,9 @@
                   let expr = T.strip exprRaw
                       afterCloseTag = T.drop 2 rest -- skip "}}"
                    in FoundIf
-                        { foundBefore = before,
-                          foundAfter = afterCloseTag,
-                          foundExpr = expr
+                        { before = before,
+                          after = afterCloseTag,
+                          expr = expr
                         }
       | "{{/if}}" `T.isPrefixOf` t =
           FoundOrphan "{{/if}}" (lineOffset (T.take pos input))
diff --git a/src/Seihou/Engine/UpdateTransaction.hs b/src/Seihou/Engine/UpdateTransaction.hs
--- a/src/Seihou/Engine/UpdateTransaction.hs
+++ b/src/Seihou/Engine/UpdateTransaction.hs
@@ -17,6 +17,7 @@
 import Data.Aeson qualified as Aeson
 import Data.ByteString.Lazy qualified as LBS
 import Data.Foldable (traverse_)
+import Data.Generics.Labels ()
 import Data.List (sortOn)
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
@@ -38,11 +39,11 @@
 import System.IO.Temp (createTempDirectory, openTempFile)
 
 data UpdateTransaction = UpdateTransaction
-  { projectRoot :: FilePath,
-    transactionDirectory :: FilePath,
-    targets :: Set FilePath
+  { projectRoot :: !FilePath,
+    transactionDirectory :: !FilePath,
+    targets :: !(Set FilePath)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 data TransactionError
   = InvalidTransactionPath FilePath Text
@@ -57,25 +58,25 @@
   deriving stock (Eq, Show)
 
 data JournalEntry = JournalEntry
-  { targetPath :: FilePath,
-    backupFile :: Maybe FilePath
+  { targetPath :: !FilePath,
+    backupFile :: !(Maybe FilePath)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 data JournalMetadata = JournalMetadata
-  { journalVersion :: Int,
-    createdAt :: UTCTime,
-    entries :: [JournalEntry],
-    newDirectories :: [FilePath],
-    expectedManifest :: Maybe Manifest
+  { journalVersion :: !Int,
+    createdAt :: !UTCTime,
+    entries :: ![JournalEntry],
+    newDirectories :: ![FilePath],
+    expectedManifest :: !(Maybe Manifest)
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 instance ToJSON JournalEntry where
   toJSON entry =
     Aeson.object
-      [ "path" .= entry.targetPath,
-        "backup" .= entry.backupFile
+      [ "path" .= (entry ^. #targetPath),
+        "backup" .= (entry ^. #backupFile)
       ]
 
 instance FromJSON JournalEntry where
@@ -85,11 +86,11 @@
 instance ToJSON JournalMetadata where
   toJSON metadata =
     Aeson.object
-      [ "version" .= metadata.journalVersion,
-        "createdAt" .= metadata.createdAt,
-        "entries" .= metadata.entries,
-        "newDirectories" .= metadata.newDirectories,
-        "expectedManifest" .= metadata.expectedManifest
+      [ "version" .= (metadata ^. #journalVersion),
+        "createdAt" .= (metadata ^. #createdAt),
+        "entries" .= (metadata ^. #entries),
+        "newDirectories" .= (metadata ^. #newDirectories),
+        "expectedManifest" .= (metadata ^. #expectedManifest)
       ]
 
 instance FromJSON JournalMetadata where
@@ -118,10 +119,10 @@
 
 initializeJournal :: UpdateTransaction -> [FilePath] -> IO ()
 initializeJournal transaction safeTargets = do
-  let backupDirectory = transaction.transactionDirectory </> "backups"
+  let backupDirectory = transaction ^. #transactionDirectory </> "backups"
   Directory.createDirectoryIfMissing True backupDirectory
   entries <- forM (zip [0 :: Int ..] safeTargets) $ \(index, relativePath) -> do
-    let fullPath = transaction.projectRoot </> relativePath
+    let fullPath = transaction ^. #projectRoot </> relativePath
         backupName = show index <> ".txt"
         backupPath = backupDirectory </> backupName
     exists <- Directory.doesFileExist fullPath
@@ -130,10 +131,10 @@
         TIO.readFile fullPath >>= TIO.writeFile backupPath
         pure (JournalEntry relativePath (Just backupName))
       else pure (JournalEntry relativePath Nothing)
-  missingDirectories <- missingParentDirectories transaction.projectRoot safeTargets
+  missingDirectories <- missingParentDirectories (transaction ^. #projectRoot) safeTargets
   now <- getCurrentTime
   writeJournal
-    transaction.transactionDirectory
+    (transaction ^. #transactionDirectory)
     JournalMetadata
       { journalVersion = 1,
         createdAt = now,
@@ -188,22 +189,22 @@
                 Right () -> pure (Right candidate)
 
 transactionPreflight :: UpdateTransaction -> ReconciliationPlan -> IO (Either TransactionError ())
-transactionPreflight transaction plan = case traverse validateTransactionPath (Set.toAscList plan.requiredDirectories) of
+transactionPreflight transaction plan = case traverse validateTransactionPath (Set.toAscList (plan ^. #requiredDirectories)) of
   Left err -> pure (Left err)
   Right _
     | not (Set.null unjournaled) -> pure (Left (TransactionUnjournaledPaths unjournaled))
     | not (Set.null unresolved) -> pure (Left (TransactionUnresolvedPaths unresolved))
     | otherwise -> verifyObservedFiles transaction plan
   where
-    unjournaled = Map.keysSet plan.files Set.\\ transaction.targets
+    unjournaled = Map.keysSet (plan ^. #files) Set.\\ (transaction ^. #targets)
     unresolved = unresolvedPaths plan
 
 verifyObservedFiles :: UpdateTransaction -> ReconciliationPlan -> IO (Either TransactionError ())
-verifyObservedFiles transaction plan = go (Map.toAscList plan.files)
+verifyObservedFiles transaction plan = go (Map.toAscList (plan ^. #files))
   where
     go [] = pure (Right ())
     go ((path, reconciliation) : rest) = do
-      current <- observeDiskFile (transaction.projectRoot </> path)
+      current <- observeDiskFile (transaction ^. #projectRoot </> path)
       let planned = reconciliationObservation reconciliation
       if current == planned
         then go rest
@@ -232,23 +233,23 @@
 
 prepareCandidateManifest :: UpdateTransaction -> ReconciliationPlan -> Manifest -> IO Manifest
 prepareCandidateManifest transaction plan manifest = do
-  nextFiles <- foldM applyManifestAction manifest.files (Map.toAscList plan.files)
+  nextFiles <- foldM applyManifestAction (manifest ^. #files) (Map.toAscList (plan ^. #files))
   pure (replaceManifestFiles manifest nextFiles)
   where
     applyManifestAction files (path, reconciliation) = case desiredState reconciliation of
       Just (desired, state) -> do
-        baseline <- writeBaselineBlob transaction.projectRoot state.generatedBaseline
+        baseline <- writeBaselineBlob (transaction ^. #projectRoot) (state ^. #generatedBaseline)
         let record =
               FileRecord
-                { hash = state.recordedHash,
-                  moduleName = desired.moduleName,
-                  strategy = desired.strategy,
-                  generatedAt = manifest.genAt,
+                { hash = state ^. #recordedHash,
+                  moduleName = desired ^. #moduleName,
+                  strategy = desired ^. #strategy,
+                  generatedAt = manifest ^. #genAt,
                   baseline = Just baseline,
-                  applicationIds = desired.applicationIds
+                  applicationIds = desired ^. #applicationIds
                 }
         pure (Map.insert path record files)
-      Nothing -> pure (applyOrphanManifestAction plan.applicationIds reconciliation files)
+      Nothing -> pure (applyOrphanManifestAction (plan ^. #applicationIds) reconciliation files)
 
 desiredState :: FileReconciliation -> Maybe (DesiredFile, PlannedFileState)
 desiredState reconciliation = case reconciliation of
@@ -256,7 +257,7 @@
   FileUpdate desired state _ _ -> Just (desired, state)
   FileAutoMerge desired state _ _ -> Just (desired, state)
   FileUnchanged desired state _ _ -> Just (desired, state)
-  FileConflict desired _ _ _ _ _ (Just resolution) -> Just (desired, resolution.state)
+  FileConflict desired _ _ _ _ _ (Just resolution) -> Just (desired, resolution ^. #state)
   _ -> Nothing
 
 applyOrphanManifestAction ::
@@ -268,14 +269,14 @@
   FileDeleteSafe path _ _ -> Map.delete path files
   FileAlreadyAbsent path _ _ -> Map.delete path files
   FileReleaseSharedOwnership path record _ ->
-    let remaining = record.applicationIds Set.\\ selected
+    let remaining = (record ^. #applicationIds) Set.\\ selected
      in if Set.null remaining
           then Map.delete path files
           else Map.insert path (replaceRecordApplications record remaining) files
   FileOrphanEdited path _ _ _ (Just DeleteEditedOrphan) -> Map.delete path files
   FileOrphanEdited _ _ _ _ (Just RetainTrackedOrphan) -> files
   FileOrphanEdited path record _ _ (Just DetachAndKeepOrphan) ->
-    let remaining = record.applicationIds Set.\\ selected
+    let remaining = (record ^. #applicationIds) Set.\\ selected
      in if Set.null remaining
           then Map.delete path files
           else Map.insert path (replaceRecordApplications record remaining) files
@@ -284,26 +285,26 @@
 replaceRecordApplications :: FileRecord -> Set ApplicationId -> FileRecord
 replaceRecordApplications record owners =
   FileRecord
-    { hash = record.hash,
-      moduleName = record.moduleName,
-      strategy = record.strategy,
-      generatedAt = record.generatedAt,
-      baseline = record.baseline,
+    { hash = record ^. #hash,
+      moduleName = record ^. #moduleName,
+      strategy = record ^. #strategy,
+      generatedAt = record ^. #generatedAt,
+      baseline = record ^. #baseline,
       applicationIds = owners
     }
 
 replaceManifestFiles :: Manifest -> Map FilePath FileRecord -> Manifest
 replaceManifestFiles manifest nextFiles =
   Manifest
-    { version = manifest.version,
-      genAt = manifest.genAt,
-      modules = manifest.modules,
-      vars = manifest.vars,
+    { version = manifest ^. #version,
+      genAt = manifest ^. #genAt,
+      modules = manifest ^. #modules,
+      vars = manifest ^. #vars,
       files = nextFiles,
-      applications = manifest.applications,
-      recipe = manifest.recipe,
-      blueprint = manifest.blueprint,
-      blueprintMigrations = manifest.blueprintMigrations
+      applications = manifest ^. #applications,
+      recipe = manifest ^. #recipe,
+      blueprint = manifest ^. #blueprint,
+      blueprintMigrations = manifest ^. #blueprintMigrations
     }
 
 writeBaselineBlob :: FilePath -> Text -> IO BaselineRef
@@ -313,50 +314,50 @@
 
 updateJournalForPlan :: UpdateTransaction -> ReconciliationPlan -> Manifest -> IO (Either TransactionError ())
 updateJournalForPlan transaction plan candidate = do
-  metadataResult <- readJournal transaction.transactionDirectory
+  metadataResult <- readJournal (transaction ^. #transactionDirectory)
   case metadataResult of
     Left err -> pure (Left err)
     Right metadata -> do
       missingDirectories <-
         filterMIO
-          (fmap not . Directory.doesDirectoryExist . (transaction.projectRoot </>))
+          (fmap not . Directory.doesDirectoryExist . (transaction ^. #projectRoot </>))
           ( Set.toAscList . Set.fromList $
               concatMap
                 (\path -> path : relativeParents path)
-                (Set.toAscList plan.requiredDirectories)
+                (Set.toAscList (plan ^. #requiredDirectories))
           )
       let updated = setExpectedManifestAndDirectories metadata missingDirectories candidate
-      result <- try @SomeException $ writeJournal transaction.transactionDirectory updated
+      result <- try @SomeException $ writeJournal (transaction ^. #transactionDirectory) updated
       pure $ first (\err -> TransactionApplyFailed (exceptionText err) Nothing) result
 
 setExpectedManifestAndDirectories :: JournalMetadata -> [FilePath] -> Manifest -> JournalMetadata
 setExpectedManifestAndDirectories metadata additionalDirectories candidate =
   JournalMetadata
-    { journalVersion = metadata.journalVersion,
-      createdAt = metadata.createdAt,
-      entries = metadata.entries,
+    { journalVersion = metadata ^. #journalVersion,
+      createdAt = metadata ^. #createdAt,
+      entries = metadata ^. #entries,
       newDirectories =
         sortOn pathDepth . Set.toList $
-          Set.fromList (metadata.newDirectories <> additionalDirectories),
+          Set.fromList (metadata ^. #newDirectories <> additionalDirectories),
       expectedManifest = Just candidate
     }
 
 applyMutations :: (Int -> IO ()) -> UpdateTransaction -> ReconciliationPlan -> IO ()
 applyMutations afterMutation transaction plan = do
-  forM_ (Set.toAscList plan.requiredDirectories) $ \relativePath ->
-    Directory.createDirectoryIfMissing True (transaction.projectRoot </> relativePath)
-  _ <- foldM applyOne (0 :: Int) (Map.toAscList plan.files)
+  forM_ (Set.toAscList (plan ^. #requiredDirectories)) $ \relativePath ->
+    Directory.createDirectoryIfMissing True (transaction ^. #projectRoot </> relativePath)
+  _ <- foldM applyOne (0 :: Int) (Map.toAscList (plan ^. #files))
   pure ()
   where
     applyOne count (path, reconciliation) = case mutationFor reconciliation of
       NoMutation -> pure count
       WriteMutation content -> do
-        atomicWriteText (transaction.projectRoot </> path) content
+        atomicWriteText (transaction ^. #projectRoot </> path) content
         let next = count + 1
         afterMutation next
         pure next
       DeleteMutation -> do
-        let fullPath = transaction.projectRoot </> path
+        let fullPath = transaction ^. #projectRoot </> path
         exists <- Directory.doesFileExist fullPath
         when exists (Directory.removeFile fullPath)
         let next = count + 1
@@ -368,7 +369,7 @@
 mutationFor :: FileReconciliation -> FileMutation
 mutationFor reconciliation = case desiredState reconciliation of
   Just (_, state)
-    | state.writeToDisk -> WriteMutation state.appliedContent
+    | state ^. #writeToDisk -> WriteMutation (state ^. #appliedContent)
     | otherwise -> NoMutation
   Nothing -> case reconciliation of
     FileDeleteSafe _ _ _ -> DeleteMutation
@@ -377,25 +378,25 @@
 
 rollbackUpdateTransaction :: UpdateTransaction -> IO (Either TransactionError ())
 rollbackUpdateTransaction transaction = do
-  metadataResult <- readJournal transaction.transactionDirectory
+  metadataResult <- readJournal (transaction ^. #transactionDirectory)
   case metadataResult of
     Left err -> pure (Left err)
     Right metadata -> do
       result <- try @SomeException $ do
-        forM_ metadata.entries (restoreEntry transaction)
-        removeNewDirectories transaction.projectRoot metadata.newDirectories
-        cleanupDirectory transaction.transactionDirectory
+        forM_ (metadata ^. #entries) (restoreEntry transaction)
+        removeNewDirectories (transaction ^. #projectRoot) (metadata ^. #newDirectories)
+        cleanupDirectory (transaction ^. #transactionDirectory)
       pure $ first (TransactionRollbackFailed . exceptionText) result
 
 restoreEntry :: UpdateTransaction -> JournalEntry -> IO ()
 restoreEntry transaction entry = do
-  let target = transaction.projectRoot </> entry.targetPath
-  case entry.backupFile of
+  let target = transaction ^. #projectRoot </> (entry ^. #targetPath)
+  case entry ^. #backupFile of
     Nothing -> do
       exists <- Directory.doesFileExist target
       when exists (Directory.removeFile target)
     Just backupName -> do
-      let backupPath = transaction.transactionDirectory </> "backups" </> backupName
+      let backupPath = transaction ^. #transactionDirectory </> "backups" </> backupName
       content <- TIO.readFile backupPath
       atomicWriteText target content
 
@@ -411,7 +412,7 @@
 
 completeUpdateTransaction :: UpdateTransaction -> IO (Either TransactionError ())
 completeUpdateTransaction transaction = do
-  result <- try @SomeException (cleanupDirectory transaction.transactionDirectory)
+  result <- try @SomeException (cleanupDirectory (transaction ^. #transactionDirectory))
   pure $ first (TransactionCompletionFailed . exceptionText) result
 
 -- | Replace the recovery commit marker with the exact manifest the caller is
@@ -420,19 +421,19 @@
 -- candidate before the atomic manifest write.
 setUpdateTransactionExpectedManifest :: UpdateTransaction -> Manifest -> IO (Either TransactionError ())
 setUpdateTransactionExpectedManifest transaction expected = do
-  metadataResult <- readJournal transaction.transactionDirectory
+  metadataResult <- readJournal (transaction ^. #transactionDirectory)
   case metadataResult of
     Left err -> pure (Left err)
     Right metadata -> do
       let updated =
             JournalMetadata
-              { journalVersion = metadata.journalVersion,
-                createdAt = metadata.createdAt,
-                entries = metadata.entries,
-                newDirectories = metadata.newDirectories,
+              { journalVersion = metadata ^. #journalVersion,
+                createdAt = metadata ^. #createdAt,
+                entries = metadata ^. #entries,
+                newDirectories = metadata ^. #newDirectories,
                 expectedManifest = Just expected
               }
-      result <- try @SomeException (writeJournal transaction.transactionDirectory updated)
+      result <- try @SomeException (writeJournal (transaction ^. #transactionDirectory) updated)
       pure $ first (\err -> TransactionApplyFailed (exceptionText err) Nothing) result
 
 recoverIncompleteTransactions :: FilePath -> IO [Either TransactionError ()]
@@ -460,7 +461,7 @@
         Left quarantineError -> Left quarantineError
         Right () -> Left err
     Right metadata -> do
-      committed <- manifestMatches projectRoot metadata.expectedManifest
+      committed <- manifestMatches projectRoot (metadata ^. #expectedManifest)
       if committed
         then do
           result <- try @SomeException (cleanupDirectory transactionDirectory)
@@ -470,7 +471,7 @@
             UpdateTransaction
               { projectRoot = projectRoot,
                 transactionDirectory = transactionDirectory,
-                targets = Set.fromList (map (.targetPath) metadata.entries)
+                targets = Set.fromList (map (^. #targetPath) (metadata ^. #entries))
               }
 
 manifestMatches :: FilePath -> Maybe Manifest -> IO Bool
@@ -507,9 +508,9 @@
 
 validateJournal :: JournalMetadata -> Either Text JournalMetadata
 validateJournal metadata = do
-  traverse_ (validateJournalEntry . (.targetPath)) metadata.entries
-  traverse_ validateBackupName [name | JournalEntry _ (Just name) <- metadata.entries]
-  traverse_ (first renderTransactionPathError . validateTransactionPath) metadata.newDirectories
+  traverse_ (validateJournalEntry . (^. #targetPath)) (metadata ^. #entries)
+  traverse_ validateBackupName [name | JournalEntry _ (Just name) <- metadata ^. #entries]
+  traverse_ (first renderTransactionPathError . validateTransactionPath) (metadata ^. #newDirectories)
   pure metadata
   where
     validateJournalEntry path = first renderTransactionPathError (validateTransactionPath path)
diff --git a/src/Seihou/Engine/Validate.hs b/src/Seihou/Engine/Validate.hs
--- a/src/Seihou/Engine/Validate.hs
+++ b/src/Seihou/Engine/Validate.hs
@@ -8,6 +8,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Maybe (isNothing, mapMaybe)
 import Data.Set qualified as Set
@@ -40,21 +41,21 @@
 
 -- | A single diagnostic check with its result.
 data DiagCheck = DiagCheck
-  { diagLabel :: Text,
-    diagSeverity :: DiagSeverity,
-    diagDetails :: [Text]
+  { label :: !Text,
+    severity :: !DiagSeverity,
+    details :: ![Text]
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | A complete validation report for a module.
 data ValidateReport = ValidateReport
-  { reportModule :: Module,
-    reportPath :: FilePath,
-    reportDhallOk :: Bool,
-    reportDhallError :: Maybe Text,
-    reportChecks :: [DiagCheck]
+  { module_ :: !Module,
+    path :: !FilePath,
+    dhallOk :: !Bool,
+    dhallError :: !(Maybe Text),
+    checks :: ![DiagCheck]
   }
-  deriving stock (Eq, Show)
+  deriving stock (Eq, Generic, Show)
 
 -- | Build a structured validation report. When the first argument is True,
 -- lint warnings are included after the core checks.
@@ -94,24 +95,24 @@
           else []
   pure
     ValidateReport
-      { reportModule = m,
-        reportPath = baseDir,
-        reportDhallOk = True,
-        reportDhallError = Nothing,
-        reportChecks = coreChecks ++ lintChecks
+      { module_ = m,
+        path = baseDir,
+        dhallOk = True,
+        dhallError = Nothing,
+        checks = coreChecks ++ lintChecks
       }
 
 -- | Whether the report contains any errors (DiagError with non-empty details).
 reportHasErrors :: ValidateReport -> Bool
 reportHasErrors report =
-  not report.reportDhallOk
-    || any (\c -> c.diagSeverity == DiagError && not (null c.diagDetails)) report.reportChecks
+  not (report ^. #dhallOk)
+    || any (\c -> c ^. #severity == DiagError && not (null (c ^. #details))) (report ^. #checks)
 
 -- | Render the report as plain text (no ANSI codes).
 renderReportPlain :: ValidateReport -> Text
 renderReportPlain report =
   T.unlines $
-    [ "Validating module at " <> T.pack report.reportPath <> "...",
+    [ "Validating module at " <> T.pack (report ^. #path) <> "...",
       ""
     ]
       ++ dhallLine
@@ -120,46 +121,46 @@
       ++ [""]
       ++ [resultLine]
   where
-    m = report.reportModule
+    m = (report ^. #module_)
 
     dhallLine =
-      if report.reportDhallOk
+      if report ^. #dhallOk
         then ["  \x2713 module.dhall evaluates successfully"]
         else
           ["  \x2717 module.dhall failed to evaluate"]
-            ++ case report.reportDhallError of
+            ++ case report ^. #dhallError of
               Just errText -> ["      " <> errText]
               Nothing -> []
 
     summaryLines =
-      if report.reportDhallOk
+      if report ^. #dhallOk
         then
-          [ "  \x2713 Module name: " <> m.name.unModuleName,
-            "  \x2713 " <> T.pack (show (length m.vars)) <> " variables declared",
-            "  \x2713 " <> T.pack (show (length m.prompts)) <> " prompts defined",
-            "  \x2713 " <> T.pack (show (length m.steps)) <> " steps defined"
+          [ "  \x2713 Module name: " <> m ^. #name . #unModuleName,
+            "  \x2713 " <> T.pack (show (length (m ^. #vars))) <> " variables declared",
+            "  \x2713 " <> T.pack (show (length (m ^. #prompts))) <> " prompts defined",
+            "  \x2713 " <> T.pack (show (length (m ^. #steps))) <> " steps defined"
           ]
         else []
 
-    checkLines = concatMap renderCheck report.reportChecks
+    checkLines = concatMap renderCheck (report ^. #checks)
 
     renderCheck c
-      | null c.diagDetails =
-          ["  \x2713 " <> c.diagLabel]
-      | c.diagSeverity == DiagWarning =
-          ("  \x26A0 " <> c.diagLabel) : map (\d -> "      " <> d) c.diagDetails
+      | null (c ^. #details) =
+          ["  \x2713 " <> c ^. #label]
+      | c ^. #severity == DiagWarning =
+          ("  \x26A0 " <> c ^. #label) : map (\d -> "      " <> d) (c ^. #details)
       | otherwise =
-          ("  \x2717 " <> c.diagLabel) : map (\d -> "      " <> d) c.diagDetails
+          ("  \x2717 " <> c ^. #label) : map (\d -> "      " <> d) (c ^. #details)
 
     errorCount =
       length
         [ ()
-        | c <- report.reportChecks,
-          c.diagSeverity == DiagError,
-          not (null c.diagDetails)
+        | c <- report ^. #checks,
+          c ^. #severity == DiagError,
+          not (null (c ^. #details))
         ]
 
-    dhallFailed = not report.reportDhallOk
+    dhallFailed = not (report ^. #dhallOk)
 
     totalErrors = errorCount + (if dhallFailed then 1 else 0)
 
@@ -167,7 +168,7 @@
       | totalErrors > 0 =
           T.pack (show totalErrors) <> " error(s) found. Module is invalid."
       | otherwise =
-          "Module '" <> m.name.unModuleName <> "' is valid."
+          "Module '" <> m ^. #name . #unModuleName <> "' is valid."
 
 -- Lint checks
 
@@ -176,40 +177,40 @@
 lintUnusedVars m =
   let destRefs =
         Set.fromList $
-          concatMap (extractPlaceholders . (.dest)) m.steps
+          concatMap (extractPlaceholders . (^. #dest)) (m ^. #steps)
       exportRefs =
         Set.fromList $
-          map (.var.unVarName) m.exports
+          map (^. #var . #unVarName) (m ^. #exports)
       promptRefs =
         Set.fromList $
-          map (.var.unVarName) m.prompts
+          map (^. #var . #unVarName) (m ^. #prompts)
       allRefs = Set.unions [destRefs, exportRefs, promptRefs]
    in mapMaybe
         ( \v ->
-            let name' = v.name.unVarName
+            let name' = (v ^. #name . #unVarName)
              in if Set.member name' allRefs
                   then Nothing
                   else Just ("variable '" <> name' <> "' is declared but never referenced")
         )
-        m.vars
+        (m ^. #vars)
 
 -- | Required variables that have no corresponding prompt.
 lintRequiredWithoutPrompt :: Module -> [Text]
 lintRequiredWithoutPrompt m =
-  let promptedVars = Set.fromList $ map (.var.unVarName) m.prompts
+  let promptedVars = Set.fromList $ map (^. #var . #unVarName) (m ^. #prompts)
    in mapMaybe
         ( \v ->
-            let name' = v.name.unVarName
-             in if v.required && not (Set.member name' promptedVars)
+            let name' = (v ^. #name . #unVarName)
+             in if v ^. #required && not (Set.member name' promptedVars)
                   then Just ("required variable '" <> name' <> "' has no prompt")
                   else Nothing
         )
-        m.vars
+        (m ^. #vars)
 
 -- | Steps that write to the same destination (excluding patch ops).
 lintDuplicateDestinations :: Module -> [Text]
 lintDuplicateDestinations m =
-  let nonPatchDests = [s.dest | s <- m.steps, isNothing s.patch]
+  let nonPatchDests = [s ^. #dest | s <- m ^. #steps, isNothing (s ^. #patch)]
       dupes = findDuplicates Set.empty Set.empty nonPatchDests
    in map (\d -> "multiple steps write to '" <> d <> "'") dupes
 
@@ -224,22 +225,22 @@
 lintEmptyChoices :: Module -> [Text]
 lintEmptyChoices m =
   mapMaybe
-    ( \v -> case v.type_ of
-        VTChoice [] -> Just ("variable '" <> v.name.unVarName <> "' has an empty choice list")
+    ( \v -> case v ^. #type_ of
+        VTChoice [] -> Just ("variable '" <> v ^. #name . #unVarName <> "' has an empty choice list")
         _ -> Nothing
     )
-    m.vars
+    (m ^. #vars)
 
 -- | Variables without a description.
 lintMissingDescriptions :: Module -> [Text]
 lintMissingDescriptions m =
   mapMaybe
     ( \v ->
-        if isNothing v.description
-          then Just ("variable '" <> v.name.unVarName <> "' has no description")
+        if isNothing (v ^. #description)
+          then Just ("variable '" <> v ^. #name . #unVarName <> "' has no description")
           else Nothing
     )
-    m.vars
+    (m ^. #vars)
 
 -- Conditional-expression lint (when clauses + template {{#if}} conditionals)
 
@@ -261,15 +262,15 @@
 lintConditionals :: FilePath -> Module -> IO ([Text], [Text])
 lintConditionals baseDir m = do
   templateExprs <- collectTemplateExprs baseDir m
-  let declaredTypes = Map.fromList [(d.name, d.type_) | d <- m.vars]
+  let declaredTypes = Map.fromList [(d ^. #name, d ^. #type_) | d <- m ^. #vars]
       stepExprs =
-        [("step '" <> s.dest <> "' when clause", c) | s <- m.steps, Just c <- [s.condition]]
+        [("step '" <> s ^. #dest <> "' when clause", c) | s <- m ^. #steps, Just c <- [s ^. #condition]]
       commandExprs =
-        [("command when clause", c) | c0 <- m.commands, Just c <- [c0.condition]]
+        [("command when clause", c) | c0 <- m ^. #commands, Just c <- [c0 ^. #condition]]
       promptExprs =
-        [ ("prompt for '" <> p.var.unVarName <> "' when clause", c)
-        | p <- m.prompts,
-          Just c <- [p.condition]
+        [ ("prompt for '" <> p ^. #var . #unVarName <> "' when clause", c)
+        | p <- m ^. #prompts,
+          Just c <- [p ^. #condition]
         ]
       allExprs = stepExprs ++ commandExprs ++ promptExprs ++ templateExprs
       findings = concatMap (uncurry (lintExpr declaredTypes)) allExprs
@@ -287,20 +288,20 @@
 collectTemplateExprs baseDir m =
   concat <$> mapM readStep textBearingSteps
   where
-    textBearingSteps = filter (isTextBearing . (.strategy)) m.steps
+    textBearingSteps = filter (isTextBearing . (^. #strategy)) (m ^. #steps)
 
     isTextBearing Template = True
     isTextBearing DhallText = True
     isTextBearing _ = False
 
     readStep s = do
-      let path = baseDir </> "files" </> s.src
+      let path = baseDir </> "files" </> (s ^. #src)
       exists <- doesFileExist path
       if not exists
         then pure []
         else do
           contents <- TIO.readFile path
-          let label = "template '" <> T.pack s.src <> "' {{#if}} condition"
+          let label = "template '" <> T.pack (s ^. #src) <> "' {{#if}} condition"
           pure [(label, expr) | raw <- extractIfExprs contents, Right expr <- [parseExpr raw]]
 
 -- | Lint a single expression from the given source against the declared types.
@@ -311,7 +312,7 @@
     checkRef (name, mLit) =
       case Map.lookup name declaredTypes of
         Nothing ->
-          [CondUndeclared (srcLabel <> " references undeclared variable: " <> name.unVarName)]
+          [CondUndeclared (srcLabel <> " references undeclared variable: " <> name ^. #unVarName)]
         Just ty -> case mLit of
           Just lit
             | not (literalMatchesType ty lit) ->
@@ -337,7 +338,7 @@
 describeMismatch srcLabel name ty lit =
   srcLabel
     <> " compares variable '"
-    <> name.unVarName
+    <> name ^. #unVarName
     <> "' (declared type "
     <> renderVarType ty
     <> ") against "
diff --git a/src/Seihou/Interaction/Confirm.hs b/src/Seihou/Interaction/Confirm.hs
--- a/src/Seihou/Interaction/Confirm.hs
+++ b/src/Seihou/Interaction/Confirm.hs
@@ -4,6 +4,7 @@
 where
 
 import Control.Monad (foldM)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Seihou.Composition.Instance (ModuleInstance (..))
 import Seihou.Core.Types
@@ -43,7 +44,7 @@
 anyNeedsConfirm = any (any isDefaultOrParent) . Map.elems
 
 isDefaultOrParent :: ResolvedVar -> Bool
-isDefaultOrParent rv = case rv.source of
+isDefaultOrParent rv = case rv ^. #source of
   FromDefault -> True
   FromParent _ -> True
   _ -> False
@@ -55,7 +56,7 @@
   Eff es (Map ModuleInstance (Map VarName ResolvedVar))
 processInstance acc (inst, m, _dir) = do
   let modResolved = Map.findWithDefault Map.empty inst acc
-  newModResolved <- foldM (processVar m acc) modResolved m.vars
+  newModResolved <- foldM (processVar m acc) modResolved (m ^. #vars)
   pure (Map.insert inst newModResolved acc)
 
 processVar ::
@@ -66,29 +67,29 @@
   VarDecl ->
   Eff es (Map VarName ResolvedVar)
 processVar m allResolved modResolved decl =
-  case Map.lookup decl.name modResolved of
+  case Map.lookup (decl ^. #name) modResolved of
     Just rv | isDefaultOrParent rv -> do
       let prompt = findOrSynthesize m decl
           currentBindings =
-            Map.map (.value) (Map.unions (Map.elems allResolved))
+            Map.map (^. #value) (Map.unions (Map.elems allResolved))
       result <- promptForVar prompt decl currentBindings
       case result of
         Left _err -> pure modResolved
         Right newRv
-          | newRv.value == rv.value -> pure modResolved
-          | otherwise -> pure (Map.insert decl.name newRv modResolved)
+          | newRv ^. #value == rv ^. #value -> pure modResolved
+          | otherwise -> pure (Map.insert (decl ^. #name) newRv modResolved)
     _ -> pure modResolved
 
 -- | Find the authored 'Prompt' for a variable, or build a minimal one
 -- from the declaration.
 findOrSynthesize :: Module -> VarDecl -> Prompt
 findOrSynthesize m decl =
-  case filter (\p -> p.var == decl.name) m.prompts of
+  case filter (\p -> p ^. #var == decl ^. #name) (m ^. #prompts) of
     (p : _) -> p
     [] ->
       Prompt
-        { var = decl.name,
-          text = decl.name.unVarName,
+        { var = decl ^. #name,
+          text = decl ^. #name . #unVarName,
           condition = Nothing,
           choices = Nothing
         }
diff --git a/src/Seihou/Interaction/Prompt.hs b/src/Seihou/Interaction/Prompt.hs
--- a/src/Seihou/Interaction/Prompt.hs
+++ b/src/Seihou/Interaction/Prompt.hs
@@ -4,6 +4,7 @@
   )
 where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Core.Expr (evalExpr)
@@ -32,12 +33,12 @@
 runPrompts prompts unresolvedDecls currentBindings =
   go prompts Map.empty
   where
-    declMap = Map.fromList [(d.name, d) | d <- unresolvedDecls]
+    declMap = Map.fromList [(d ^. #name, d) | d <- unresolvedDecls]
 
     go :: (Console :> es) => [Prompt] -> Map VarName ResolvedVar -> Eff es (Map VarName ResolvedVar)
     go [] acc = pure acc
     go (p : ps) acc = do
-      let vn = p.var
+      let vn = (p ^. #var)
       -- Skip if variable is not in the unresolved set
       case Map.lookup vn declMap of
         Nothing -> go ps acc
@@ -47,7 +48,7 @@
             then go ps acc
             else do
               -- Evaluate when condition
-              let allBindings = Map.union (Map.map (.value) acc) currentBindings
+              let allBindings = Map.union (Map.map (^. #value) acc) currentBindings
               if shouldPrompt p allBindings
                 then do
                   result <- promptForVar p decl allBindings
@@ -59,7 +60,7 @@
 -- | Check if a prompt should be displayed based on its @when@ condition.
 shouldPrompt :: Prompt -> Map VarName VarValue -> Bool
 shouldPrompt p bindings =
-  case p.condition of
+  case p ^. #condition of
     Nothing -> True
     Just expr -> evalExpr bindings expr
 
@@ -73,7 +74,7 @@
   Map VarName VarValue ->
   Eff es (Either VarError ResolvedVar)
 promptForVar prompt decl _bindings =
-  case prompt.choices of
+  case prompt ^. #choices of
     Just choices -> promptWithChoices prompt decl choices
     Nothing -> promptFreeText prompt decl 3
 
@@ -92,7 +93,7 @@
   putText (formatPromptText prompt decl)
   raw <- getLine
   if T.null (T.strip raw)
-    then case decl.default_ of
+    then case decl ^. #default_ of
       Just defVal ->
         -- Accept the default value
         pure
@@ -104,14 +105,14 @@
                 }
           )
       Nothing
-        | not decl.required ->
+        | not (decl ^. #required) ->
             -- Optional variable with no default — skip
-            pure (Left (MissingRequiredVar decl.name))
+            pure (Left (MissingRequiredVar (decl ^. #name)))
         | retriesLeft > 1 -> do
             putText "Value cannot be empty. Please try again."
             promptFreeText prompt decl (retriesLeft - 1)
         | otherwise ->
-            pure (Left (MissingRequiredVar decl.name))
+            pure (Left (MissingRequiredVar (decl ^. #name)))
     else case coerceAndValidate decl raw of
       Left err ->
         if retriesLeft > 1
@@ -129,7 +130,7 @@
   [Text] ->
   Eff es (Either VarError ResolvedVar)
 promptWithChoices prompt decl choices = do
-  putText prompt.text
+  putText (prompt ^. #text)
   mapM_ (\(i, c) -> putText ("  " <> T.pack (show i) <> ") " <> c)) (zip [1 :: Int ..] choices)
   putText "Enter selection number:"
   raw <- getLine
@@ -151,12 +152,12 @@
                in case coerceAndValidate decl chosen of
                     Left err -> pure (Left err)
                     Right rv -> pure (Right rv)
-        _ -> pure (Left (MissingRequiredVar decl.name))
+        _ -> pure (Left (MissingRequiredVar (decl ^. #name)))
 
 -- | Coerce raw text to the variable's type and validate.
 coerceAndValidate :: VarDecl -> Text -> Either VarError ResolvedVar
 coerceAndValidate decl raw = do
-  val <- coerceValue decl.name decl.type_ raw
+  val <- coerceValue (decl ^. #name) (decl ^. #type_) raw
   validateVarValue decl val
   pure
     ResolvedVar
@@ -185,11 +186,11 @@
 -- For optional variables without a default, appends @[skip]@.
 formatPromptText :: Prompt -> VarDecl -> Text
 formatPromptText prompt decl =
-  case decl.default_ of
-    Just defVal -> prompt.text <> " [" <> showDefaultValue defVal <> "]:"
+  case decl ^. #default_ of
+    Just defVal -> prompt ^. #text <> " [" <> showDefaultValue defVal <> "]:"
     Nothing
-      | not decl.required -> prompt.text <> " [skip]:"
-      | otherwise -> prompt.text
+      | not (decl ^. #required) -> prompt ^. #text <> " [skip]:"
+      | otherwise -> (prompt ^. #text)
 
 -- | Render a VarValue for display in a prompt's default hint.
 showDefaultValue :: VarValue -> Text
diff --git a/src/Seihou/Manifest/Types.hs b/src/Seihou/Manifest/Types.hs
--- a/src/Seihou/Manifest/Types.hs
+++ b/src/Seihou/Manifest/Types.hs
@@ -6,6 +6,7 @@
     writeAppliedBlueprint,
     writeAppliedBlueprintMigration,
     hasAppliedBlueprintMigration,
+    artifactOriginName,
   )
 where
 
@@ -13,6 +14,7 @@
 import Data.Aeson qualified as Aeson
 import Data.Aeson.Types qualified as Aeson
 import Data.ByteString.Lazy qualified as LBS
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -20,6 +22,7 @@
 import Seihou.Core.Types
 import Seihou.Manifest.Hash (baselineRefFromText)
 import Seihou.Prelude hiding ((.=))
+import System.FilePath (takeFileName)
 
 -- | Current manifest schema version.
 --
@@ -40,8 +43,15 @@
 --
 -- Bumped from 4 to 5 when 'Manifest' gained the durable
 -- @blueprintMigrations@ receipt ledger. A missing ledger decodes as empty.
+--
+-- Bumped from 5 to 6 when every recorded artifact reference gained a
+-- portable @origin@ and the machine-specific @source@ / @targetSource@
+-- absolute paths were dropped from the serialized form
+-- (see docs/plans/76-record-portable-artifact-origins-in-the-manifest.md).
+-- Version-5-and-earlier manifests are not readable directly; see
+-- docs/plans/79-upgrade-legacy-absolute-path-manifests-in-place.md.
 currentManifestVersion :: Int
-currentManifestVersion = 5
+currentManifestVersion = 6
 
 -- | Create an empty manifest with the given timestamp.
 emptyManifest :: UTCTime -> Manifest
@@ -65,15 +75,15 @@
 writeAppliedBlueprint :: AppliedBlueprint -> Manifest -> Manifest
 writeAppliedBlueprint ab m =
   Manifest
-    { version = m.version,
-      genAt = m.genAt,
-      modules = m.modules,
-      vars = m.vars,
-      files = m.files,
-      applications = m.applications,
-      recipe = m.recipe,
+    { version = m ^. #version,
+      genAt = m ^. #genAt,
+      modules = m ^. #modules,
+      vars = m ^. #vars,
+      files = m ^. #files,
+      applications = m ^. #applications,
+      recipe = m ^. #recipe,
       blueprint = Just ab,
-      blueprintMigrations = m.blueprintMigrations
+      blueprintMigrations = m ^. #blueprintMigrations
     }
 
 -- | Insert or replace one exact blueprint migration receipt. Replacement is
@@ -83,20 +93,20 @@
 writeAppliedBlueprintMigration receipt manifest =
   Manifest
     { version = currentManifestVersion,
-      genAt = manifest.genAt,
-      modules = manifest.modules,
-      vars = manifest.vars,
-      files = manifest.files,
-      applications = manifest.applications,
-      recipe = manifest.recipe,
-      blueprint = manifest.blueprint,
-      blueprintMigrations = upsert manifest.blueprintMigrations
+      genAt = manifest ^. #genAt,
+      modules = manifest ^. #modules,
+      vars = manifest ^. #vars,
+      files = manifest ^. #files,
+      applications = manifest ^. #applications,
+      recipe = manifest ^. #recipe,
+      blueprint = manifest ^. #blueprint,
+      blueprintMigrations = upsert (manifest ^. #blueprintMigrations)
     }
   where
     sameEdge existing =
-      existing.name == receipt.name
-        && existing.fromVersion == receipt.fromVersion
-        && existing.toVersion == receipt.toVersion
+      existing ^. #name == receipt ^. #name
+        && existing ^. #fromVersion == receipt ^. #fromVersion
+        && existing ^. #toVersion == (receipt ^. #toVersion)
 
     upsert receipts
       | any sameEdge receipts = map (\existing -> if sameEdge existing then receipt else existing) receipts
@@ -107,11 +117,11 @@
 hasAppliedBlueprintMigration blueprintName fromVersion toVersion manifest =
   any
     ( \receipt ->
-        receipt.name == blueprintName
-          && receipt.fromVersion == fromVersion
-          && receipt.toVersion == toVersion
+        receipt ^. #name == blueprintName
+          && receipt ^. #fromVersion == fromVersion
+          && receipt ^. #toVersion == toVersion
     )
-    manifest.blueprintMigrations
+    (manifest ^. #blueprintMigrations)
 
 -- | Encode a manifest to JSON bytes.
 manifestToJSON :: Manifest -> LBS.ByteString
@@ -126,39 +136,59 @@
 instance ToJSON Manifest where
   toJSON m =
     Aeson.object $
-      [ "version" .= m.version,
-        "generatedAt" .= m.genAt,
-        "modules" .= m.modules,
-        "variables" .= varsToJSON m.vars,
-        "files" .= filesToJSON m.files,
-        "applications" .= m.applications,
-        "blueprintMigrations" .= m.blueprintMigrations
+      [ "version" .= (m ^. #version),
+        "generatedAt" .= (m ^. #genAt),
+        "modules" .= (m ^. #modules),
+        "variables" .= varsToJSON (m ^. #vars),
+        "files" .= filesToJSON (m ^. #files),
+        "applications" .= (m ^. #applications),
+        "blueprintMigrations" .= (m ^. #blueprintMigrations)
       ]
-        ++ maybe [] (\r -> ["recipe" .= r]) m.recipe
-        ++ maybe [] (\b -> ["blueprint" .= b]) m.blueprint
+        ++ maybe [] (\r -> ["recipe" .= r]) (m ^. #recipe)
+        ++ maybe [] (\b -> ["blueprint" .= b]) (m ^. #blueprint)
 
 instance FromJSON Manifest where
   parseJSON = Aeson.withObject "Manifest" $ \o -> do
     v <- o .: "version"
-    if v > currentManifestVersion
-      then fail "manifest was created by a newer version of seihou"
-      else
-        Manifest
-          <$> pure v
-          <*> o .: "generatedAt"
-          <*> o .: "modules"
-          <*> (varsFromJSON =<< o .: "variables")
-          <*> (filesFromJSON =<< o .: "files")
-          <*> o Aeson..:? "applications" Aeson..!= []
-          <*> o Aeson..:? "recipe"
-          <*> o Aeson..:? "blueprint"
-          <*> o Aeson..:? "blueprintMigrations" Aeson..!= []
+    checkManifestVersion v
+    Manifest
+      <$> pure v
+      <*> o .: "generatedAt"
+      <*> o .: "modules"
+      <*> (varsFromJSON =<< o .: "variables")
+      <*> (filesFromJSON =<< o .: "files")
+      <*> o Aeson..:? "applications" Aeson..!= []
+      <*> o Aeson..:? "recipe"
+      <*> o Aeson..:? "blueprint"
+      <*> o Aeson..:? "blueprintMigrations" Aeson..!= []
 
+-- | Reject a manifest this build cannot read, naming the remedy.
+--
+-- Compatibility seam: schema-5-and-earlier manifests carry an absolute
+-- @source@ path in place of the portable @origin@. Decoding those is owned
+-- by docs/plans/79-upgrade-legacy-absolute-path-manifests-in-place.md; until
+-- that plan lands an older manifest fails here with a clear message rather
+-- than being silently misread. The @seihou manifest upgrade@ command the
+-- message names is delivered by that same plan, so the remedy does not exist
+-- yet.
+checkManifestVersion :: Int -> Aeson.Parser ()
+checkManifestVersion v
+  | v > currentManifestVersion =
+      fail "manifest was created by a newer version of seihou"
+  | v < 6 =
+      fail
+        ( "this manifest uses schema version "
+            <> show v
+            <> ", which records machine-specific absolute paths; run "
+            <> "'seihou manifest upgrade' to convert it"
+        )
+  | otherwise = pure ()
+
 instance ToJSON AppliedTarget where
   toJSON (AppliedModuleTarget name) =
-    Aeson.object ["kind" .= ("module" :: Text), "name" .= name.unModuleName]
+    Aeson.object ["kind" .= ("module" :: Text), "name" .= (name ^. #unModuleName)]
   toJSON (AppliedRecipeTarget name) =
-    Aeson.object ["kind" .= ("recipe" :: Text), "name" .= name.unRecipeName]
+    Aeson.object ["kind" .= ("recipe" :: Text), "name" .= (name ^. #unRecipeName)]
 
 instance FromJSON AppliedTarget where
   parseJSON = Aeson.withObject "AppliedTarget" $ \o -> do
@@ -169,15 +199,58 @@
       "recipe" -> pure (AppliedRecipeTarget (RecipeName name))
       other -> fail ("unknown applied target kind: " <> T.unpack other)
 
+-- | Machine-independent artifact references are encoded as a tagged object
+-- so a manifest diff stays readable and so future constructors can be added
+-- without breaking the shape.
+instance ToJSON ArtifactOrigin where
+  toJSON (RemoteOrigin url artifact repo) =
+    Aeson.object $
+      [ "kind" .= ("remote" :: Text),
+        "url" .= url,
+        "artifact" .= artifact
+      ]
+        ++ maybe [] (\value -> ["repo" .= value]) repo
+  toJSON (ProjectOrigin path) =
+    Aeson.object
+      [ "kind" .= ("project" :: Text),
+        "path" .= T.pack path
+      ]
+  toJSON (LocalOrigin artifact) =
+    Aeson.object
+      [ "kind" .= ("local" :: Text),
+        "artifact" .= artifact
+      ]
+
+instance FromJSON ArtifactOrigin where
+  parseJSON = Aeson.withObject "ArtifactOrigin" $ \o -> do
+    kind <- o .: "kind" :: Aeson.Parser Text
+    case kind of
+      "remote" ->
+        RemoteOrigin
+          <$> o .: "url"
+          <*> o .: "artifact"
+          <*> o Aeson..:? "repo"
+      "project" -> ProjectOrigin . T.unpack <$> o .: "path"
+      "local" -> LocalOrigin <$> o .: "artifact"
+      other -> fail ("unknown artifact origin kind: " <> T.unpack other)
+
+-- | The artifact name an origin refers to, for display and for matching
+-- against a discovered artifact. 'ProjectOrigin' derives it from the last
+-- path segment, which is how @.seihou\/modules\/\<name\>@ is laid out.
+artifactOriginName :: ArtifactOrigin -> Text
+artifactOriginName (RemoteOrigin _ artifact _) = artifact
+artifactOriginName (LocalOrigin artifact) = artifact
+artifactOriginName (ProjectOrigin path) = T.pack (takeFileName path)
+
 instance ToJSON AppliedInstanceState where
   toJSON state =
     Aeson.object $
-      [ "name" .= state.name.unModuleName,
-        "source" .= state.source,
-        "resolvedVars" .= varsToJSON state.resolvedVars
+      [ "name" .= (state ^. #name . #unModuleName),
+        "origin" .= (state ^. #origin),
+        "resolvedVars" .= varsToJSON (state ^. #resolvedVars)
       ]
-        ++ parentVarsField state.parentVars
-        ++ maybe [] (\v -> ["version" .= v]) state.moduleVersion
+        ++ parentVarsField (state ^. #parentVars)
+        ++ maybe [] (\v -> ["version" .= v]) (state ^. #moduleVersion)
     where
       parentVarsField (ParentVars m)
         | Map.null m = []
@@ -192,24 +265,24 @@
     AppliedInstanceState
       <$> (ModuleName <$> o .: "name")
       <*> pure pv
-      <*> o .: "source"
+      <*> o .: "origin"
       <*> o Aeson..:? "version"
       <*> (varsFromJSON =<< o Aeson..:? "resolvedVars" Aeson..!= Aeson.object [])
 
 instance ToJSON AppliedComposition where
   toJSON composition =
     Aeson.object $
-      [ "applicationId" .= composition.applicationId.unApplicationId,
-        "target" .= composition.target,
-        "targetSource" .= composition.targetSource,
-        "additionalModules" .= map (.unModuleName) composition.additionalModules,
-        "instances" .= composition.instances,
-        "appliedAt" .= composition.appliedAt
+      [ "applicationId" .= (composition ^. #applicationId . #unApplicationId),
+        "target" .= (composition ^. #target),
+        "targetOrigin" .= (composition ^. #targetOrigin),
+        "additionalModules" .= map (^. #unModuleName) (composition ^. #additionalModules),
+        "instances" .= (composition ^. #instances),
+        "appliedAt" .= (composition ^. #appliedAt)
       ]
-        ++ maybe [] (\v -> ["targetVersion" .= v]) composition.targetVersion
-        ++ maybe [] (\v -> ["namespace" .= v]) composition.namespace
-        ++ maybe [] (\v -> ["context" .= v]) composition.context
-        ++ commandReceiptsField composition.commandReceipts
+        ++ maybe [] (\v -> ["targetVersion" .= v]) (composition ^. #targetVersion)
+        ++ maybe [] (\v -> ["namespace" .= v]) (composition ^. #namespace)
+        ++ maybe [] (\v -> ["context" .= v]) (composition ^. #context)
+        ++ commandReceiptsField (composition ^. #commandReceipts)
     where
       commandReceiptsField receipts
         | Map.null receipts = []
@@ -220,7 +293,7 @@
     AppliedComposition
       <$> (ApplicationId <$> o .: "applicationId")
       <*> o .: "target"
-      <*> o .: "targetSource"
+      <*> o .: "targetOrigin"
       <*> o Aeson..:? "targetVersion"
       <*> (map ModuleName <$> o Aeson..:? "additionalModules" Aeson..!= [])
       <*> o Aeson..:? "namespace"
@@ -232,12 +305,12 @@
 instance ToJSON CommandReceipt where
   toJSON receipt =
     Aeson.object $
-      [ "fingerprint" .= commandFingerprintText receipt.fingerprint,
-        "module" .= receipt.moduleName.unModuleName,
-        "command" .= receipt.command,
-        "completedAt" .= receipt.completedAt
+      [ "fingerprint" .= commandFingerprintText (receipt ^. #fingerprint),
+        "module" .= (receipt ^. #moduleName . #unModuleName),
+        "command" .= (receipt ^. #command),
+        "completedAt" .= (receipt ^. #completedAt)
       ]
-        ++ maybe [] (\path -> ["workDir" .= path]) receipt.workDir
+        ++ maybe [] (\path -> ["workDir" .= path]) (receipt ^. #workDir)
 
 instance FromJSON CommandReceipt where
   parseJSON = Aeson.withObject "CommandReceipt" $ \o ->
@@ -251,10 +324,10 @@
 instance ToJSON AppliedRecipe where
   toJSON ar =
     Aeson.object $
-      [ "name" .= ar.name.unRecipeName,
-        "appliedAt" .= ar.appliedAt
+      [ "name" .= (ar ^. #name . #unRecipeName),
+        "appliedAt" .= (ar ^. #appliedAt)
       ]
-        ++ maybe [] (\v -> ["version" .= v]) ar.recipeVersion
+        ++ maybe [] (\v -> ["version" .= v]) (ar ^. #recipeVersion)
 
 instance FromJSON AppliedRecipe where
   parseJSON = Aeson.withObject "AppliedRecipe" $ \o ->
@@ -266,14 +339,14 @@
 instance ToJSON AppliedBlueprint where
   toJSON ab =
     Aeson.object $
-      [ "name" .= ab.name.unModuleName,
-        "appliedAt" .= ab.appliedAt,
-        "baselineModules" .= map (.unModuleName) ab.baselineModules,
-        "noBaseline" .= ab.noBaseline
+      [ "name" .= (ab ^. #name . #unModuleName),
+        "appliedAt" .= (ab ^. #appliedAt),
+        "baselineModules" .= map (^. #unModuleName) (ab ^. #baselineModules),
+        "noBaseline" .= (ab ^. #noBaseline)
       ]
-        ++ maybe [] (\v -> ["version" .= v]) ab.blueprintVersion
-        ++ maybe [] (\p -> ["userPrompt" .= p]) ab.userPrompt
-        ++ maybe [] (\s -> ["agentSessionId" .= s]) ab.agentSessionId
+        ++ maybe [] (\v -> ["version" .= v]) (ab ^. #blueprintVersion)
+        ++ maybe [] (\p -> ["userPrompt" .= p]) (ab ^. #userPrompt)
+        ++ maybe [] (\s -> ["agentSessionId" .= s]) (ab ^. #agentSessionId)
 
 instance FromJSON AppliedBlueprint where
   parseJSON = Aeson.withObject "AppliedBlueprint" $ \o ->
@@ -289,13 +362,13 @@
 instance ToJSON AppliedBlueprintMigration where
   toJSON receipt =
     Aeson.object $
-      [ "name" .= receipt.name.unModuleName,
-        "from" .= receipt.fromVersion,
-        "to" .= receipt.toVersion,
-        "appliedAt" .= receipt.appliedAt
+      [ "name" .= (receipt ^. #name . #unModuleName),
+        "from" .= (receipt ^. #fromVersion),
+        "to" .= (receipt ^. #toVersion),
+        "appliedAt" .= (receipt ^. #appliedAt)
       ]
-        ++ maybe [] (\version -> ["version" .= version]) receipt.blueprintVersion
-        ++ maybe [] (\sessionId -> ["agentSessionId" .= sessionId]) receipt.agentSessionId
+        ++ maybe [] (\version -> ["version" .= version]) (receipt ^. #blueprintVersion)
+        ++ maybe [] (\sessionId -> ["agentSessionId" .= sessionId]) (receipt ^. #agentSessionId)
 
 instance FromJSON AppliedBlueprintMigration where
   parseJSON = Aeson.withObject "AppliedBlueprintMigration" $ \o ->
@@ -310,13 +383,13 @@
 instance ToJSON AppliedModule where
   toJSON am =
     Aeson.object $
-      [ "name" .= am.name.unModuleName,
-        "source" .= am.source,
-        "appliedAt" .= am.appliedAt
+      [ "name" .= (am ^. #name . #unModuleName),
+        "origin" .= (am ^. #origin),
+        "appliedAt" .= (am ^. #appliedAt)
       ]
-        ++ parentVarsField am.parentVars
-        ++ maybe [] (\v -> ["version" .= v]) am.moduleVersion
-        ++ maybe [] (\r -> ["removal" .= removalToJSON r]) am.removal
+        ++ parentVarsField (am ^. #parentVars)
+        ++ maybe [] (\v -> ["version" .= v]) (am ^. #moduleVersion)
+        ++ maybe [] (\r -> ["removal" .= removalToJSON r]) (am ^. #removal)
     where
       parentVarsField (ParentVars m)
         | Map.null m = []
@@ -341,13 +414,13 @@
     AppliedModule
       <$> (ModuleName <$> o .: "name")
       <*> pure pv
-      <*> o .: "source"
+      <*> o .: "origin"
       <*> o Aeson..:? "version"
       <*> o .: "appliedAt"
       <*> pure removal
 
 parentVarsMapToJSON :: Map VarName Text -> Aeson.Value
-parentVarsMapToJSON = toJSON . Map.mapKeys (.unVarName)
+parentVarsMapToJSON = toJSON . Map.mapKeys (^. #unVarName)
 
 parentVarsMapFromJSON :: Aeson.Value -> Aeson.Parser (Map VarName Text)
 parentVarsMapFromJSON v = do
@@ -358,17 +431,17 @@
 removalToJSON :: Removal -> Aeson.Value
 removalToJSON r =
   Aeson.object
-    [ "steps" .= map removalStepToJSON r.removalSteps,
-      "commands" .= map removalCommandToJSON r.removalCommands
+    [ "steps" .= map removalStepToJSON (r ^. #steps),
+      "commands" .= map removalCommandToJSON (r ^. #commands)
     ]
 
 removalStepToJSON :: RemovalStep -> Aeson.Value
 removalStepToJSON s =
   Aeson.object $
-    [ "action" .= removalActionToText s.action,
-      "dest" .= s.dest
+    [ "action" .= removalActionToText (s ^. #action),
+      "dest" .= (s ^. #dest)
     ]
-      ++ maybe [] (\p -> ["src" .= p]) s.src
+      ++ maybe [] (\p -> ["src" .= p]) (s ^. #src)
 
 removalActionToText :: RemovalAction -> Text
 removalActionToText RemoveFileAction = "remove-file"
@@ -378,8 +451,8 @@
 removalCommandToJSON :: Command -> Aeson.Value
 removalCommandToJSON c =
   Aeson.object $
-    ["run" .= c.run]
-      ++ maybe [] (\w -> ["workDir" .= w]) c.workDir
+    ["run" .= (c ^. #run)]
+      ++ maybe [] (\w -> ["workDir" .= w]) (c ^. #workDir)
 
 -- | Parse a Removal from JSON.
 parseRemovalJSON :: Aeson.Value -> Aeson.Parser Removal
@@ -411,17 +484,17 @@
 instance ToJSON FileRecord where
   toJSON fr =
     Aeson.object $
-      [ "hash" .= fr.hash.unSHA256,
-        "module" .= fr.moduleName.unModuleName,
-        "strategy" .= strategyToText fr.strategy,
-        "generatedAt" .= fr.generatedAt
+      [ "hash" .= (fr ^. #hash . #unSHA256),
+        "module" .= (fr ^. #moduleName . #unModuleName),
+        "strategy" .= strategyToText (fr ^. #strategy),
+        "generatedAt" .= (fr ^. #generatedAt)
       ]
-        ++ maybe [] (\ref -> ["baseline" .= ref.unBaselineRef.unSHA256]) fr.baseline
-        ++ applicationIdsField fr.applicationIds
+        ++ maybe [] (\ref -> ["baseline" .= (ref ^. #unBaselineRef . #unSHA256)]) (fr ^. #baseline)
+        ++ applicationIdsField (fr ^. #applicationIds)
     where
       applicationIdsField ids
         | Set.null ids = []
-        | otherwise = ["applications" .= map (.unApplicationId) (Set.toAscList ids)]
+        | otherwise = ["applications" .= map (^. #unApplicationId) (Set.toAscList ids)]
 
 instance FromJSON FileRecord where
   parseJSON = Aeson.withObject "FileRecord" $ \o -> do
@@ -459,7 +532,7 @@
 -- Helpers for VarName-keyed maps
 
 varsToJSON :: Map VarName Text -> Aeson.Value
-varsToJSON = toJSON . Map.mapKeys (.unVarName)
+varsToJSON = toJSON . Map.mapKeys (^. #unVarName)
 
 varsFromJSON :: Aeson.Value -> Aeson.Parser (Map VarName Text)
 varsFromJSON v = do
diff --git a/src/Seihou/Prelude.hs b/src/Seihou/Prelude.hs
--- a/src/Seihou/Prelude.hs
+++ b/src/Seihou/Prelude.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE PackageImports #-}
+{-# LANGUAGE PatternSynonyms #-}
 
 module Seihou.Prelude
   ( -- * Text
@@ -28,18 +29,11 @@
     EffectHandler,
 
     -- * Lens
-    view,
-    over,
-    set,
-    (^.),
-    (.~),
-    (%~),
-    (&),
-    lens,
-    Lens',
-    Getting,
-    ASetter,
+    module Control.Lens,
 
+    -- * Generics
+    Generic,
+
     -- * Bifunctor
     first,
 
@@ -50,11 +44,37 @@
 where
 
 import "base" Data.Bifunctor (first)
+-- Every record type in the project derives Generic, both because the house
+-- style requires it and because generic-lens synthesises #label lenses from
+-- the Generic representation. Re-exporting it here keeps the derive clauses
+-- import-free.
+import "base" GHC.Generics (Generic)
 import "containers" Data.Map.Strict (Map)
 import "containers" Data.Set (Set)
 import "effectful-core" Effectful (Dispatch (Dynamic), DispatchOf, Eff, Effect, IOE, MonadIO, liftIO, runEff, type (:>), type (:>>))
 import "effectful-core" Effectful.Dispatch.Dynamic (EffectHandler, HasCallStack, interpret, reinterpret, send)
 import "filepath" System.FilePath ((</>))
-import "generic-lens" Data.Generics.Labels ()
-import "lens" Control.Lens (ASetter, Getting, Lens', lens, over, set, view, (%~), (&), (.~), (^.))
+-- Re-export the whole lens API. PackageImports pins the package so that
+-- `Control.Lens` unambiguously means the `lens` package's module.
+--
+-- Deliberately absent: Data.Generics.Labels. Its IsLabel instance is an
+-- orphan, and orphan instances propagate transitively, so importing it here
+-- would force the generic-lens interpretation of #label onto every module in
+-- the project. Each module that uses #label imports it individually instead.
+--
+-- Four names are hidden. Each collides with a name seihou already has in
+-- scope, and none of the four is a lens combinator seihou has any use for:
+--
+--   (.=)      collides with Data.Aeson's (.=), used unqualified by the
+--             hand-written ToJSON instances in eleven modules. The lens (.=)
+--             is the MonadState assignment operator; seihou uses effectful's
+--             State with `modify` and never needs it.
+--   argument  collides with Options.Applicative.argument, imported openly by
+--             Seihou.CLI.Commands. The lens `argument` is a Setter over a
+--             Profunctor's argument position.
+--   List      collides with the `List` constructor of Seihou.CLI.Commands's
+--             Command type. The lens `List` is an IsList pattern synonym.
+--   Context   collides with the `Context` constructor of the same type. The
+--             lens `Context` is the indexed store comonad.
+import "lens" Control.Lens hiding (Context (..), argument, (.=), pattern List)
 import "text" Data.Text (Text)
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -7,6 +7,8 @@
 import Seihou.Composition.ResolveSpec qualified as ResolveSpec
 import Seihou.Core.AgentPromptSpec qualified as AgentPromptSpec
 import Seihou.Core.ApplicationSpec qualified as ApplicationSpec
+import Seihou.Core.ArtifactOriginDetectSpec qualified as ArtifactOriginDetectSpec
+import Seihou.Core.ArtifactRefSpec qualified as ArtifactRefSpec
 import Seihou.Core.BlueprintSpec qualified as BlueprintSpec
 import Seihou.Core.CommandFingerprintSpec qualified as CommandFingerprintSpec
 import Seihou.Core.CommandVarSpec qualified as CommandVarSpec
@@ -71,6 +73,8 @@
   resolveTests <- ResolveSpec.tests
   agentPromptTests <- AgentPromptSpec.tests
   applicationTests <- ApplicationSpec.tests
+  artifactOriginDetectTests <- ArtifactOriginDetectSpec.tests
+  artifactRefTests <- ArtifactRefSpec.tests
   blueprintTests <- BlueprintSpec.tests
   commandFingerprintTests <- CommandFingerprintSpec.tests
   commandVarTests <- CommandVarSpec.tests
@@ -124,4 +128,4 @@
   manifestTypesTests <- ManifestTypesSpec.tests
   promptTests <- PromptSpec.tests
   confirmTests <- ConfirmSpec.tests
-  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, promptTests, confirmTests])
+  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, promptTests, confirmTests])
diff --git a/test/Seihou/Composition/GraphSpec.hs b/test/Seihou/Composition/GraphSpec.hs
--- a/test/Seihou/Composition/GraphSpec.hs
+++ b/test/Seihou/Composition/GraphSpec.hs
@@ -1,6 +1,8 @@
 module Seihou.Composition.GraphSpec (tests) where
 
+import Control.Lens ((^.))
 import Data.Either (isLeft)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Seihou.Composition.Graph
 import Seihou.Composition.Instance (ModuleInstance (..), mkInstance, primaryInstance)
@@ -33,7 +35,7 @@
 -- primary 'ModuleInstance' (no parent bindings). Works for all the
 -- bare-name test scenarios below.
 fromModules :: [Module] -> CompositionGraph
-fromModules ms = buildGraph [(primaryInstance m.name, m) | m <- ms]
+fromModules ms = buildGraph [(primaryInstance (m ^. #name), m) | m <- ms]
 
 spec :: Spec
 spec = do
@@ -41,21 +43,21 @@
     it "builds a graph from a single module with no dependencies" $ do
       let m = mkModule "base" []
           g = fromModules [m]
-      length g.cgModules `shouldBe` 1
-      length g.cgEdges `shouldBe` 1
+      length (g ^. #modules) `shouldBe` 1
+      length (g ^. #edges) `shouldBe` 1
 
     it "builds a graph preserving dependency edges" $ do
       let a = mkModule "a" ["b", "c"]
           b = mkModule "b" []
           c = mkModule "c" []
           g = fromModules [a, b, c]
-      length g.cgModules `shouldBe` 3
-      length g.cgEdges `shouldBe` 3
+      length (g ^. #modules) `shouldBe` 3
+      length (g ^. #edges) `shouldBe` 3
 
   describe "topoSort" $ do
     it "returns a single module with no dependencies" $ do
       let g = fromModules [mkModule "base" []]
-      fmap (map (.instanceModule)) (topoSort g) `shouldBe` Right ["base"]
+      fmap (map (^. #module_)) (topoSort g) `shouldBe` Right ["base"]
 
     it "orders a linear chain: A -> B -> C" $ do
       let a = mkModule "a" ["b"]
@@ -64,7 +66,7 @@
           g = fromModules [a, b, c]
       case topoSort g of
         Right order -> do
-          let names = map (.instanceModule) order
+          let names = map (^. #module_) order
           indexOf "c" names `shouldSatisfy` (< indexOf "b" names)
           indexOf "b" names `shouldSatisfy` (< indexOf "a" names)
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
@@ -77,7 +79,7 @@
           g = fromModules [a, b, c, d]
       case topoSort g of
         Right order -> do
-          let names = map (.instanceModule) order
+          let names = map (^. #module_) order
           length names `shouldBe` 4
           indexOf "d" names `shouldSatisfy` (< indexOf "b" names)
           indexOf "d" names `shouldSatisfy` (< indexOf "c" names)
@@ -115,7 +117,7 @@
           g = fromModules [a, b, c, d, e]
       case topoSort g of
         Right order -> do
-          let names = map (.instanceModule) order
+          let names = map (^. #module_) order
           length names `shouldBe` 5
           indexOf "e" names `shouldSatisfy` (< indexOf "d" names)
           indexOf "e" names `shouldSatisfy` (< indexOf "c" names)
@@ -125,7 +127,7 @@
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
 
   describe "multi-instantiation" $ do
-    it "treats two dependency edges with different depVars as distinct instances" $ do
+    it "treats two dependency edges with different vars as distinct instances" $ do
       let helper = mkModule "helper" []
           -- Parent depends on 'helper' twice with different bindings.
           parent' =
diff --git a/test/Seihou/Composition/InstanceSpec.hs b/test/Seihou/Composition/InstanceSpec.hs
--- a/test/Seihou/Composition/InstanceSpec.hs
+++ b/test/Seihou/Composition/InstanceSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Composition.InstanceSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Composition.Instance
@@ -21,8 +23,8 @@
     it "appends a stable hash suffix when parent bindings are present" $ do
       let inst = mkInstance "claude-skill-link" (ParentVars (Map.singleton "skill.name" "exec-plan"))
           qn = qualifiedName inst
-      T.isPrefixOf "claude-skill-link#" (qn.unModuleName) `shouldBe` True
-      T.length (qn.unModuleName) `shouldBe` T.length "claude-skill-link#" + 8
+      T.isPrefixOf "claude-skill-link#" (qn ^. #unModuleName) `shouldBe` True
+      T.length (qn ^. #unModuleName) `shouldBe` T.length "claude-skill-link#" + 8
 
     it "produces a distinct qualified name for each distinct binding" $ do
       let a = mkInstance "claude-skill-link" (ParentVars (Map.singleton "skill.name" "exec-plan"))
diff --git a/test/Seihou/Composition/ResolveSpec.hs b/test/Seihou/Composition/ResolveSpec.hs
--- a/test/Seihou/Composition/ResolveSpec.hs
+++ b/test/Seihou/Composition/ResolveSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Composition.ResolveSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict (Map)
 import Data.Map.Strict qualified as Map
 import Seihou.Composition.Instance (ModuleInstance (..), mkInstance, primaryInstance)
@@ -71,7 +73,7 @@
 -- using 'emptyParentVars' for every module. Existing single-instance
 -- tests use this to migrate onto the new API without churn.
 asInstances :: [(Module, FilePath)] -> [(ModuleInstance, Module, FilePath)]
-asInstances pairs = [(primaryInstance m.name, m, dir) | (m, dir) <- pairs]
+asInstances pairs = [(primaryInstance (m ^. #name), m, dir) | (m, dir) <- pairs]
 
 -- | Look up the resolved variables for a module by its bare name,
 -- assuming the composition contains a single primary instance of it.
@@ -108,7 +110,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "project.name") `shouldBe` VText "default"
+          (^. #value) (baseVars Map.! "project.name") `shouldBe` VText "default"
 
     it "reuses saved instance values below CLI and above ambient sources" $ do
       let m = mkModule "base" [] [mkTextVar "project.name" (Just (VText "new-default")) False] []
@@ -120,11 +122,11 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let resolved = result Map.! instanceId Map.! "project.name"
-          resolved.value `shouldBe` VText "accepted"
-          resolved.source `shouldBe` FromApplication
+          (resolved ^. #value) `shouldBe` VText "accepted"
+          (resolved ^. #source) `shouldBe` FromApplication
       case resolveComposedVariablesWithSaved modules saved (Map.singleton "project.name" "explicit") env "" "" Map.empty Map.empty Map.empty Map.empty of
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
-        Right result -> (result Map.! instanceId Map.! "project.name").source `shouldBe` FromCLI
+        Right result -> ((result Map.! instanceId Map.! "project.name") ^. #source) `shouldBe` FromCLI
 
     it "re-coerces saved values through changed candidate declarations" $ do
       let countDecl =
@@ -161,8 +163,8 @@
         Right result -> do
           let resolved = result Map.! instanceId
           Map.keys resolved `shouldBe` ["project.kept", "project.new"]
-          (resolved Map.! "project.kept").source `shouldBe` FromApplication
-          (resolved Map.! "project.new").source `shouldBe` FromDefault
+          ((resolved Map.! "project.kept") ^. #source) `shouldBe` FromApplication
+          ((resolved Map.! "project.new") ^. #source) `shouldBe` FromDefault
 
     it "flows exported variable from dependency to dependent" $ do
       let base = mkModule "base" [] [mkTextVar "project.name" (Just (VText "my-app")) False] [mkExport "project.name"]
@@ -172,7 +174,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let appVars = byName "app" result
-          (.value) (appVars Map.! "project.name") `shouldBe` VText "my-app"
+          (^. #value) (appVars Map.! "project.name") `shouldBe` VText "my-app"
 
     it "export overrides module's own default" $ do
       let base = mkModule "base" [] [mkTextVar "project.name" (Just (VText "from-base")) False] [mkExport "project.name"]
@@ -182,7 +184,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let appVars = byName "app" result
-          (.value) (appVars Map.! "project.name") `shouldBe` VText "from-base"
+          (^. #value) (appVars Map.! "project.name") `shouldBe` VText "from-base"
 
     it "CLI override beats exported value" $ do
       let base = mkModule "base" [] [mkTextVar "project.name" (Just (VText "from-base")) False] [mkExport "project.name"]
@@ -193,7 +195,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let appVars = byName "app" result
-          (.value) (appVars Map.! "project.name") `shouldBe` VText "from-cli"
+          (^. #value) (appVars Map.! "project.name") `shouldBe` VText "from-cli"
 
     it "inherits non-declared exports from dependency" $ do
       let base = mkModule "base" [] [mkTextVar "project.name" (Just (VText "my-app")) False] [mkExport "project.name"]
@@ -205,9 +207,9 @@
         Right result -> do
           let appVars = byName "app" result
           -- app inherits project.name even though it doesn't declare it
-          (.value) (appVars Map.! "project.name") `shouldBe` VText "my-app"
+          (^. #value) (appVars Map.! "project.name") `shouldBe` VText "my-app"
           -- app also has its own variable
-          (.value) (appVars Map.! "app.version") `shouldBe` VText "1.0"
+          (^. #value) (appVars Map.! "app.version") `shouldBe` VText "1.0"
 
     it "handles aliased exports" $ do
       let base = mkModule "base" [] [mkTextVar "project.name" (Just (VText "my-app")) False] [mkExportAs "project.name" "app.name"]
@@ -217,7 +219,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let appVars = byName "app" result
-          (.value) (appVars Map.! "app.name") `shouldBe` VText "my-app"
+          (^. #value) (appVars Map.! "app.name") `shouldBe` VText "my-app"
 
     it "handles diamond dependency with shared export" $ do
       let d = mkModule "d" [] [mkTextVar "sys.arch" (Just (VText "x86_64")) False] [mkExport "sys.arch"]
@@ -229,10 +231,10 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           -- All modules should see sys.arch from d
-          (.value) (byName "d" result Map.! "sys.arch") `shouldBe` VText "x86_64"
-          (.value) (byName "b" result Map.! "sys.arch") `shouldBe` VText "x86_64"
-          (.value) (byName "c" result Map.! "sys.arch") `shouldBe` VText "x86_64"
-          (.value) (byName "a" result Map.! "sys.arch") `shouldBe` VText "x86_64"
+          (^. #value) (byName "d" result Map.! "sys.arch") `shouldBe` VText "x86_64"
+          (^. #value) (byName "b" result Map.! "sys.arch") `shouldBe` VText "x86_64"
+          (^. #value) (byName "c" result Map.! "sys.arch") `shouldBe` VText "x86_64"
+          (^. #value) (byName "a" result Map.! "sys.arch") `shouldBe` VText "x86_64"
 
   describe "resolveComposedVariables (with config layers)" $ do
     it "resolves from global config when no other source provides value" $ do
@@ -243,8 +245,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "license") `shouldBe` VText "MIT"
-          (.source) (baseVars Map.! "license") `shouldBe` FromGlobalConfig
+          (^. #value) (baseVars Map.! "license") `shouldBe` VText "MIT"
+          (^. #source) (baseVars Map.! "license") `shouldBe` FromGlobalConfig
 
     it "local config overrides global config in composed resolution" $ do
       let m = mkModule "base" [] [mkTextVar "license" Nothing True] []
@@ -255,8 +257,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "license") `shouldBe` VText "BSD3"
-          (.source) (baseVars Map.! "license") `shouldBe` FromLocalConfig
+          (^. #value) (baseVars Map.! "license") `shouldBe` VText "BSD3"
+          (^. #source) (baseVars Map.! "license") `shouldBe` FromLocalConfig
 
     it "CLI override beats config layers in composed resolution" $ do
       let m = mkModule "base" [] [mkTextVar "license" Nothing True] []
@@ -268,8 +270,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "license") `shouldBe` VText "cli-license"
-          (.source) (baseVars Map.! "license") `shouldBe` FromCLI
+          (^. #value) (baseVars Map.! "license") `shouldBe` VText "cli-license"
+          (^. #source) (baseVars Map.! "license") `shouldBe` FromCLI
 
     it "config layers flow through multi-module composition" $ do
       let base = mkModule "base" [] [mkTextVar "license" Nothing True] [mkExport "license"]
@@ -281,11 +283,11 @@
         Right result -> do
           -- base gets license from global config
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "license") `shouldBe` VText "MIT"
-          (.source) (baseVars Map.! "license") `shouldBe` FromGlobalConfig
+          (^. #value) (baseVars Map.! "license") `shouldBe` VText "MIT"
+          (^. #source) (baseVars Map.! "license") `shouldBe` FromGlobalConfig
           -- app also gets license from global config (it declares the var)
           let appVars = byName "app" result
-          (.value) (appVars Map.! "license") `shouldBe` VText "MIT"
+          (^. #value) (appVars Map.! "license") `shouldBe` VText "MIT"
 
   describe "end-to-end config hierarchy auto-resolution" $ do
     it "resolves all variables from different config layers with correct precedence" $ do
@@ -307,17 +309,17 @@
         Right result -> do
           let vars = byName "haskell-app" result
           -- CLI wins for project.name
-          (.value) (vars Map.! "project.name") `shouldBe` VText "my-app"
-          (.source) (vars Map.! "project.name") `shouldBe` FromCLI
+          (^. #value) (vars Map.! "project.name") `shouldBe` VText "my-app"
+          (^. #source) (vars Map.! "project.name") `shouldBe` FromCLI
           -- Env wins over global config for license
-          (.value) (vars Map.! "license") `shouldBe` VText "Apache"
-          (.source) (vars Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
+          (^. #value) (vars Map.! "license") `shouldBe` VText "Apache"
+          (^. #source) (vars Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
           -- Local config provides haskell.ghc
-          (.value) (vars Map.! "haskell.ghc") `shouldBe` VText "9.12.2"
-          (.source) (vars Map.! "haskell.ghc") `shouldBe` FromLocalConfig
+          (^. #value) (vars Map.! "haskell.ghc") `shouldBe` VText "9.12.2"
+          (^. #source) (vars Map.! "haskell.ghc") `shouldBe` FromLocalConfig
           -- Global config provides author.name
-          (.value) (vars Map.! "author.name") `shouldBe` VText "Jane Doe"
-          (.source) (vars Map.! "author.name") `shouldBe` FromGlobalConfig
+          (^. #value) (vars Map.! "author.name") `shouldBe` VText "Jane Doe"
+          (^. #source) (vars Map.! "author.name") `shouldBe` FromGlobalConfig
 
     it "optional variables without values are omitted, not errors" $ do
       let decls =
@@ -331,7 +333,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let vars = byName "test" result
-          (.value) (vars Map.! "project.name") `shouldBe` VText "app"
+          (^. #value) (vars Map.! "project.name") `shouldBe` VText "app"
           Map.member "optional.missing" vars `shouldBe` False
 
     it "diagnostics detect unused config keys and unresolved optional vars" $ do
@@ -347,7 +349,7 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let allResolved = Map.unions (Map.elems result)
-              allDecls = concatMap (\(_, mm, _) -> mm.vars) modules
+              allDecls = concatMap (\(_, mm, _) -> mm ^. #vars) modules
               (unusedKeys, unresolvedOpt) = diagnoseResolution allResolved allDecls Map.empty Map.empty Map.empty globalCfg
           unusedKeys `shouldBe` [VarName "typo.key"]
           unresolvedOpt `shouldBe` [VarName "optional.unset"]
@@ -361,8 +363,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "user.email") `shouldBe` VText "work@example.com"
-          (.source) (baseVars Map.! "user.email") `shouldBe` FromContextConfig "work"
+          (^. #value) (baseVars Map.! "user.email") `shouldBe` VText "work@example.com"
+          (^. #source) (baseVars Map.! "user.email") `shouldBe` FromContextConfig "work"
 
     it "context flows through multi-module composition" $ do
       let base = mkModule "base" [] [mkTextVar "user.email" Nothing True] [mkExport "user.email"]
@@ -373,9 +375,9 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "user.email") `shouldBe` VText "work@example.com"
+          (^. #value) (baseVars Map.! "user.email") `shouldBe` VText "work@example.com"
           let appVars = byName "app" result
-          (.value) (appVars Map.! "user.email") `shouldBe` VText "work@example.com"
+          (^. #value) (appVars Map.! "user.email") `shouldBe` VText "work@example.com"
 
     it "multi-module composition: config values flow through exports" $ do
       let baseDecls =
@@ -396,14 +398,14 @@
         Right result -> do
           -- base: local overrides global for project.name
           let baseVars = byName "base" result
-          (.value) (baseVars Map.! "project.name") `shouldBe` VText "local-app"
-          (.source) (baseVars Map.! "project.name") `shouldBe` FromLocalConfig
-          (.value) (baseVars Map.! "license") `shouldBe` VText "MIT"
-          (.source) (baseVars Map.! "license") `shouldBe` FromGlobalConfig
+          (^. #value) (baseVars Map.! "project.name") `shouldBe` VText "local-app"
+          (^. #source) (baseVars Map.! "project.name") `shouldBe` FromLocalConfig
+          (^. #value) (baseVars Map.! "license") `shouldBe` VText "MIT"
+          (^. #source) (baseVars Map.! "license") `shouldBe` FromGlobalConfig
           -- app: same values, same precedence (declares its own vars, config wins)
           let appVars = byName "app" result
-          (.value) (appVars Map.! "project.name") `shouldBe` VText "local-app"
-          (.value) (appVars Map.! "license") `shouldBe` VText "MIT"
+          (^. #value) (appVars Map.! "project.name") `shouldBe` VText "local-app"
+          (^. #value) (appVars Map.! "license") `shouldBe` VText "MIT"
 
   describe "resolveComposedVariables (parameterized dependencies)" $ do
     it "parent-supplied var resolves in dependency" $ do
@@ -416,8 +418,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let childVars = Map.findWithDefault Map.empty childInst result
-          (.value) (childVars Map.! "skill.name") `shouldBe` VText "exec-plan"
-          (.source) (childVars Map.! "skill.name") `shouldBe` FromParent "parent"
+          (^. #value) (childVars Map.! "skill.name") `shouldBe` VText "exec-plan"
+          (^. #source) (childVars Map.! "skill.name") `shouldBe` FromParent "parent"
 
     it "parent-supplied var overrides dependency's default" $ do
       let child = mkModule "child" [] [mkTextVar "skill.name" (Just (VText "old")) False] []
@@ -429,8 +431,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let childVars = Map.findWithDefault Map.empty childInst result
-          (.value) (childVars Map.! "skill.name") `shouldBe` VText "new"
-          (.source) (childVars Map.! "skill.name") `shouldBe` FromParent "parent"
+          (^. #value) (childVars Map.! "skill.name") `shouldBe` VText "new"
+          (^. #source) (childVars Map.! "skill.name") `shouldBe` FromParent "parent"
 
     it "CLI override beats parent-supplied var" $ do
       let child = mkModule "child" [] [mkTextVar "skill.name" Nothing True] []
@@ -443,8 +445,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let childVars = Map.findWithDefault Map.empty childInst result
-          (.value) (childVars Map.! "skill.name") `shouldBe` VText "from-cli"
-          (.source) (childVars Map.! "skill.name") `shouldBe` FromCLI
+          (^. #value) (childVars Map.! "skill.name") `shouldBe` VText "from-cli"
+          (^. #source) (childVars Map.! "skill.name") `shouldBe` FromCLI
 
     it "config beats parent-supplied var" $ do
       let child = mkModule "child" [] [mkTextVar "skill.name" (Just (VText "default")) False] []
@@ -457,8 +459,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right result -> do
           let childVars = Map.findWithDefault Map.empty childInst result
-          (.value) (childVars Map.! "skill.name") `shouldBe` VText "global-val"
-          (.source) (childVars Map.! "skill.name") `shouldBe` FromGlobalConfig
+          (^. #value) (childVars Map.! "skill.name") `shouldBe` VText "global-val"
+          (^. #source) (childVars Map.! "skill.name") `shouldBe` FromGlobalConfig
 
     it "two parents supplying different bindings produce two distinct child instances" $ do
       -- The regression case from ExecPlan 10: master-plan and exec-plan both
@@ -498,5 +500,5 @@
           Map.size result `shouldBe` 4
           let varsA = Map.findWithDefault Map.empty instA result
               varsB = Map.findWithDefault Map.empty instB result
-          (.value) (varsA Map.! "skill.name") `shouldBe` VText "exec-plan"
-          (.value) (varsB Map.! "skill.name") `shouldBe` VText "master-plan"
+          (^. #value) (varsA Map.! "skill.name") `shouldBe` VText "exec-plan"
+          (^. #value) (varsB Map.! "skill.name") `shouldBe` VText "master-plan"
diff --git a/test/Seihou/Core/AgentPromptSpec.hs b/test/Seihou/Core/AgentPromptSpec.hs
--- a/test/Seihou/Core/AgentPromptSpec.hs
+++ b/test/Seihou/Core/AgentPromptSpec.hs
@@ -1,7 +1,10 @@
 module Seihou.Core.AgentPromptSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
+import Data.List (isPrefixOf)
 import Data.Text qualified as T
-import Seihou.Core.AgentPrompt (validateAgentPrompt)
+import Seihou.Core.AgentPrompt (checkAgentPromptLaunch, validateAgentPrompt)
 import Seihou.Core.Module (DiscoveredRunnable (..), RunnableKind (..), discoverAllRunnables, discoverRunnable)
 import Seihou.Core.Types
 import Seihou.Dhall.Eval (evalAgentPromptFromFile)
@@ -33,32 +36,36 @@
 
 withAgentPromptName :: ModuleName -> AgentPrompt -> AgentPrompt
 withAgentPromptName n p =
-  AgentPrompt n p.version p.description p.prompt p.vars p.prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch
+  AgentPrompt n (p ^. #version) (p ^. #description) (p ^. #prompt) (p ^. #vars) (p ^. #prompts) (p ^. #commandVars) (p ^. #guidance) (p ^. #files) (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
 withAgentPromptPrompt :: T.Text -> AgentPrompt -> AgentPrompt
 withAgentPromptPrompt body p =
-  AgentPrompt p.name p.version p.description body p.vars p.prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) body (p ^. #vars) (p ^. #prompts) (p ^. #commandVars) (p ^. #guidance) (p ^. #files) (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
 withAgentPromptVars :: [VarDecl] -> AgentPrompt -> AgentPrompt
 withAgentPromptVars vars p =
-  AgentPrompt p.name p.version p.description p.prompt vars p.prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) (p ^. #prompt) vars (p ^. #prompts) (p ^. #commandVars) (p ^. #guidance) (p ^. #files) (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
 withAgentPromptPrompts :: [Prompt] -> AgentPrompt -> AgentPrompt
 withAgentPromptPrompts prompts p =
-  AgentPrompt p.name p.version p.description p.prompt p.vars prompts p.commandVars p.guidance p.files p.allowedTools p.tags p.launch
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) (p ^. #prompt) (p ^. #vars) prompts (p ^. #commandVars) (p ^. #guidance) (p ^. #files) (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
 withAgentPromptCommandVars :: [CommandVar] -> AgentPrompt -> AgentPrompt
 withAgentPromptCommandVars commandVars p =
-  AgentPrompt p.name p.version p.description p.prompt p.vars p.prompts commandVars p.guidance p.files p.allowedTools p.tags p.launch
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) (p ^. #prompt) (p ^. #vars) (p ^. #prompts) commandVars (p ^. #guidance) (p ^. #files) (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
 withAgentPromptGuidance :: [PromptGuidance] -> AgentPrompt -> AgentPrompt
 withAgentPromptGuidance guidance p =
-  AgentPrompt p.name p.version p.description p.prompt p.vars p.prompts p.commandVars guidance p.files p.allowedTools p.tags p.launch
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) (p ^. #prompt) (p ^. #vars) (p ^. #prompts) (p ^. #commandVars) guidance (p ^. #files) (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
 withAgentPromptFiles :: [BlueprintFile] -> AgentPrompt -> AgentPrompt
 withAgentPromptFiles files p =
-  AgentPrompt p.name p.version p.description p.prompt p.vars p.prompts p.commandVars p.guidance files p.allowedTools p.tags p.launch
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) (p ^. #prompt) (p ^. #vars) (p ^. #prompts) (p ^. #commandVars) (p ^. #guidance) files (p ^. #allowedTools) (p ^. #tags) (p ^. #launch)
 
+withAgentPromptLaunch :: Maybe AgentLaunch -> AgentPrompt -> AgentPrompt
+withAgentPromptLaunch launch p =
+  AgentPrompt (p ^. #name) (p ^. #version) (p ^. #description) (p ^. #prompt) (p ^. #vars) (p ^. #prompts) (p ^. #commandVars) (p ^. #guidance) (p ^. #files) (p ^. #allowedTools) (p ^. #tags) launch
+
 hasError :: T.Text -> [T.Text] -> Bool
 hasError needle = any (T.isInfixOf needle)
 
@@ -73,18 +80,46 @@
         result <- evalAgentPromptFromFile (promptDir </> "prompt.dhall")
         case result of
           Right p -> do
-            p.name `shouldBe` "review-changes"
-            p.description `shouldBe` Just "Review local changes"
-            length p.commandVars `shouldBe` 1
-            p.guidance
+            (p ^. #name) `shouldBe` "review-changes"
+            (p ^. #description) `shouldBe` Just "Review local changes"
+            length (p ^. #commandVars) `shouldBe` 1
+            (p ^. #guidance)
               `shouldBe` [ PromptGuidance
                              "Repository workflow"
                              "Prefer focused validation commands."
                              (Just (ExprEq "git.branch" (VText "main")))
                          ]
-            fmap (.provider) p.launch `shouldBe` Just (Just "codex-cli")
+            -- This fixture's launch record predates the effort field, so it
+            -- doubles as the regression test that effort is defaulted rather
+            -- than required.
+            (p ^. #launch)
+              `shouldBe` Just
+                AgentLaunch
+                  { provider = Just "codex-cli",
+                    model = Nothing,
+                    effort = Nothing,
+                    mode = Nothing
+                  }
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
+    it "decodes a prompt launch record that declares an effort" $ do
+      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
+        let promptDir = tmpDir </> "deep-review"
+        createDirectoryIfMissing True promptDir
+        writeFile (promptDir </> "prompt.dhall") (samplePromptDhallWithEffort "deep-review")
+        result <- evalAgentPromptFromFile (promptDir </> "prompt.dhall")
+        case result of
+          Right p ->
+            (p ^. #launch)
+              `shouldBe` Just
+                AgentLaunch
+                  { provider = Just "claude-cli",
+                    model = Just "claude-sonnet-5",
+                    effort = Just "max",
+                    mode = Nothing
+                  }
+          Left err -> expectationFailure ("Expected Right, got: " <> show err)
+
     it "decodes prompt.dhall without guidance as an empty list" $ do
       withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
         let promptDir = tmpDir </> "review-changes"
@@ -92,7 +127,7 @@
         writeFile (promptDir </> "prompt.dhall") (samplePromptDhallWithoutGuidance "review-changes")
         result <- evalAgentPromptFromFile (promptDir </> "prompt.dhall")
         case result of
-          Right p -> p.guidance `shouldBe` []
+          Right p -> (p ^. #guidance) `shouldBe` []
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
   describe "validateAgentPrompt" $ do
@@ -100,7 +135,7 @@
       withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
         result <- validateAgentPrompt tmpDir goodAgentPrompt
         case result of
-          Right p -> p.name `shouldBe` "review-changes"
+          Right p -> (p ^. #name) `shouldBe` "review-changes"
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
     it "rejects an invalid prompt name" $ do
@@ -172,7 +207,7 @@
                 goodAgentPrompt
         result <- validateAgentPrompt tmpDir guided
         case result of
-          Right p -> length p.guidance `shouldBe` 2
+          Right p -> length (p ^. #guidance) `shouldBe` 2
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
     it "rejects guidance with blank titles or bodies" $ do
@@ -202,6 +237,34 @@
             hasError "guidance 'Missing' references undeclared variable: repo.kind" errs `shouldBe` True
           other -> expectationFailure ("Expected ValidationError, got: " <> show other)
 
+    it "rejects a blank declared launch field" $ do
+      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
+        let bad =
+              withAgentPromptLaunch
+                (Just AgentLaunch {provider = Nothing, model = Just " ", effort = Just "", mode = Nothing})
+                goodAgentPrompt
+        result <- validateAgentPrompt tmpDir bad
+        case result of
+          Left (ValidationError _ errs) -> do
+            hasError "launch.model, if specified, must not be empty" errs `shouldBe` True
+            hasError "launch.effort, if specified, must not be empty" errs `shouldBe` True
+          other -> expectationFailure ("Expected ValidationError, got: " <> show other)
+
+    it "accepts a fully populated launch record" $ do
+      withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
+        let p =
+              withAgentPromptLaunch
+                (Just AgentLaunch {provider = Just "claude-cli", model = Just "claude-sonnet-5", effort = Just "max", mode = Nothing})
+                goodAgentPrompt
+        checkAgentPromptLaunch p `shouldBe` []
+        result <- validateAgentPrompt tmpDir p
+        case result of
+          Right _ -> pure ()
+          Left err -> expectationFailure ("Expected Right, got: " <> show err)
+
+    it "accepts a prompt that declares no launch record" $
+      checkAgentPromptLaunch goodAgentPrompt `shouldBe` []
+
     it "checks referenced prompt files under files/" $ do
       withSystemTempDirectory "seihou-prompt" $ \tmpDir -> do
         let bad =
@@ -223,7 +286,7 @@
         result <- discoverRunnable [tmpDir] "review-changes"
         case result of
           Right (RunnableAgentPrompt p dir) -> do
-            p.name `shouldBe` "review-changes"
+            (p ^. #name) `shouldBe` "review-changes"
             dir `shouldBe` promptDir
           other -> expectationFailure ("Expected RunnableAgentPrompt, got: " <> show other)
 
@@ -234,7 +297,7 @@
         writeFile (promptDir </> "prompt.dhall") (samplePromptDhall "review-changes")
         found <- discoverAllRunnables [tmpDir]
         case found of
-          [DiscoveredRunnable {drKind = kind}] -> kind `shouldBe` KindPrompt
+          [DiscoveredRunnable {kind = kind}] -> kind `shouldBe` KindPrompt
           other -> expectationFailure ("Expected one discovered prompt, got: " <> show other)
 
     it "prefers blueprint.dhall over prompt.dhall in the same directory" $ do
@@ -292,6 +355,22 @@
       ", launch = Some { provider = Some \"codex-cli\", mode = None Text, model = None Text }",
       "}"
     ]
+
+-- | Like 'samplePromptDhall' but its launch record declares a model and an
+-- effort, as an artifact authored against the current schema would.
+samplePromptDhallWithEffort :: T.Text -> String
+samplePromptDhallWithEffort n =
+  unlines $
+    -- drop the closing brace and the fixture's own three-field launch line
+    filter (not . isPrefixOf ", launch =") (init (lines (samplePromptDhall n)))
+      <> [ ", launch = Some",
+           "    { provider = Some \"claude-cli\"",
+           "    , model = Some \"claude-sonnet-5\"",
+           "    , effort = Some \"max\"",
+           "    , mode = None Text",
+           "    }",
+           "}"
+         ]
 
 samplePromptDhallWithoutGuidance :: T.Text -> String
 samplePromptDhallWithoutGuidance n =
diff --git a/test/Seihou/Core/ApplicationSpec.hs b/test/Seihou/Core/ApplicationSpec.hs
--- a/test/Seihou/Core/ApplicationSpec.hs
+++ b/test/Seihou/Core/ApplicationSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.ApplicationSpec (tests) where
 
+import Control.Lens ((&), (?~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -59,7 +61,7 @@
   AppliedComposition
     { applicationId = mkApplicationId target additional,
       target = target,
-      targetSource = "/modules/root",
+      targetOrigin = LocalOrigin "root",
       targetVersion = Just "1.0.0",
       additionalModules = additional,
       namespace = Just "root",
@@ -76,7 +78,7 @@
       let first = mkApplicationId moduleTarget [ModuleName "docs"]
           second = mkApplicationId moduleTarget [ModuleName "docs"]
       first `shouldBe` second
-      T.length first.unApplicationId `shouldBe` 64
+      T.length (first ^. #unApplicationId) `shouldBe` 64
 
     it "changes when additional-root order changes" $ do
       let first = mkApplicationId moduleTarget [ModuleName "a", ModuleName "b"]
@@ -95,16 +97,19 @@
           inst1 = ModuleInstance moduleName pv1
           inst2 = ModuleInstance moduleName pv2
           modul = mkModule moduleName (Just "0.7.0")
-          modulesInOrder = [(inst1, modul, "/modules/link-skill"), (inst2, modul, "/modules/link-skill")]
+          modulesInOrder =
+            [ (inst1, modul, LocalOrigin "link-skill"),
+              (inst2, modul, LocalOrigin "link-skill")
+            ]
           resolved =
             Map.fromList
               [ (inst1, Map.singleton (VarName "skill.name") (mkResolved "skill.name" (VText "exec-plan"))),
                 (inst2, Map.singleton (VarName "skill.name") (mkResolved "skill.name" (VText "master-plan")))
               ]
           composition =
-            buildAppliedComposition moduleTarget "/modules/master-plan" (Just "0.7.0") [] (Just "docs") Nothing modulesInOrder resolved fixedTime
-      map (.parentVars) composition.instances `shouldBe` [pv1, pv2]
-      map (.resolvedVars) composition.instances
+            buildAppliedComposition moduleTarget (LocalOrigin "master-plan") (Just "0.7.0") [] (Just "docs") Nothing modulesInOrder resolved fixedTime
+      map (^. #parentVars) (composition ^. #instances) `shouldBe` [pv1, pv2]
+      map (^. #resolvedVars) (composition ^. #instances)
         `shouldBe` [Map.singleton "skill.name" "exec-plan", Map.singleton "skill.name" "master-plan"]
 
     it "keeps identity independent of versions, source paths, and resolved values" $ do
@@ -112,39 +117,39 @@
           first =
             buildAppliedComposition
               moduleTarget
-              "/old/root"
+              (LocalOrigin "root")
               (Just "1.0.0")
               ["extra"]
               Nothing
               Nothing
-              [(inst, mkModule "dep" (Just "1.0.0"), "/old/dep")]
+              [(inst, mkModule "dep" (Just "1.0.0"), LocalOrigin "dep")]
               (Map.singleton inst (Map.singleton "value" (mkResolved "value" (VText "old"))))
               fixedTime
           second =
             buildAppliedComposition
               moduleTarget
-              "/new/root"
+              (LocalOrigin "root")
               (Just "2.0.0")
               ["extra"]
               Nothing
               Nothing
-              [(inst, mkModule "dep" (Just "2.0.0"), "/new/dep")]
+              [(inst, mkModule "dep" (Just "2.0.0"), LocalOrigin "dep")]
               (Map.singleton inst (Map.singleton "value" (mkResolved "value" (VText "new"))))
               fixedTime
-      first.applicationId `shouldBe` second.applicationId
+      (first ^. #applicationId) `shouldBe` (second ^. #applicationId)
 
     it "preserves the original module or recipe target" $ do
-      let moduleComposition = buildAppliedComposition moduleTarget "/module" Nothing [] Nothing Nothing [] Map.empty fixedTime
+      let moduleComposition = buildAppliedComposition moduleTarget (LocalOrigin "module") Nothing [] Nothing Nothing [] Map.empty fixedTime
           recipeTarget = AppliedRecipeTarget "service"
-          recipeComposition = buildAppliedComposition recipeTarget "/recipe" (Just "2") [] Nothing Nothing [] Map.empty fixedTime
-      moduleComposition.target `shouldBe` moduleTarget
-      recipeComposition.target `shouldBe` recipeTarget
+          recipeComposition = buildAppliedComposition recipeTarget (LocalOrigin "recipe") (Just "2") [] Nothing Nothing [] Map.empty fixedTime
+      (moduleComposition ^. #target) `shouldBe` moduleTarget
+      (recipeComposition ^. #target) `shouldBe` recipeTarget
 
   describe "replaceAppliedComposition" $ do
     it "replaces in place and appends new applications" $ do
       let first = mkComposition moduleTarget []
           second = mkComposition (AppliedModuleTarget "other") []
-          replacement = first {targetVersion = Just "2.0.0"}
+          replacement = (first & #targetVersion ?~ "2.0.0")
           third = mkComposition (AppliedRecipeTarget "third") []
       replaceAppliedComposition replacement [first, second] `shouldBe` [replacement, second]
       replaceAppliedComposition third [first, second] `shouldBe` [first, second, third]
@@ -156,5 +161,5 @@
           prior = FileRecord (hashContent "old") "module" Template fixedTime Nothing (Set.singleton priorId)
           current = FileRecord (hashContent "new") "module" Template fixedTime (Just (BaselineRef (hashContent "generated"))) Set.empty
           attached = attachApplication currentId (Just prior) current
-      attached.applicationIds `shouldBe` Set.fromList [priorId, currentId]
-      attached.baseline `shouldBe` Just (BaselineRef (hashContent "generated"))
+      (attached ^. #applicationIds) `shouldBe` Set.fromList [priorId, currentId]
+      (attached ^. #baseline) `shouldBe` Just (BaselineRef (hashContent "generated"))
diff --git a/test/Seihou/Core/ArtifactOriginDetectSpec.hs b/test/Seihou/Core/ArtifactOriginDetectSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Seihou/Core/ArtifactOriginDetectSpec.hs
@@ -0,0 +1,83 @@
+module Seihou.Core.ArtifactOriginDetectSpec (tests) where
+
+import Seihou.Core.ArtifactOriginDetect (detectArtifactOrigin)
+import Seihou.Core.Types
+import System.Directory (createDirectoryIfMissing)
+import System.FilePath ((</>))
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Hspec
+import Test.Tasty
+import Test.Tasty.Hspec (testSpec)
+
+tests :: IO TestTree
+tests = testSpec "Seihou.Core.ArtifactOriginDetect" spec
+
+-- | Lay out a scratch project root and a sibling "installed" root, both
+-- inside one temporary directory, and hand both to the test body.
+withRoots :: (FilePath -> FilePath -> IO a) -> IO a
+withRoots body =
+  withSystemTempDirectory "seihou-artifact-origin" $ \tmpDir -> do
+    let projectRoot = tmpDir </> "project"
+        installedRoot = tmpDir </> "installed"
+    createDirectoryIfMissing True projectRoot
+    createDirectoryIfMissing True installedRoot
+    body projectRoot installedRoot
+
+spec :: Spec
+spec = do
+  describe "detectArtifactOrigin" $ do
+    it "records a directory inside the project as a project-relative origin" $ do
+      withRoots $ \projectRoot _installedRoot -> do
+        let moduleDir = projectRoot </> ".seihou" </> "modules" </> "foo"
+        createDirectoryIfMissing True moduleDir
+        origin <- detectArtifactOrigin projectRoot moduleDir
+        origin `shouldBe` ProjectOrigin ".seihou/modules/foo"
+
+    it "records an installed directory with origin metadata as a remote origin" $ do
+      withRoots $ \projectRoot installedRoot -> do
+        let moduleDir = installedRoot </> "haskell-base"
+        createDirectoryIfMissing True moduleDir
+        writeFile
+          (moduleDir </> ".seihou-origin.json")
+          "{\"sourceUrl\":\"https://github.com/shinzui/seihou-modules.git\",\"repoName\":\"seihou-modules\",\"version\":\"1.4.0\"}"
+        origin <- detectArtifactOrigin projectRoot moduleDir
+        origin
+          `shouldBe` RemoteOrigin
+            "https://github.com/shinzui/seihou-modules.git"
+            "haskell-base"
+            (Just "seihou-modules")
+
+    it "omits the repository name when the metadata does not record one" $ do
+      withRoots $ \projectRoot installedRoot -> do
+        let moduleDir = installedRoot </> "haskell-base"
+        createDirectoryIfMissing True moduleDir
+        writeFile
+          (moduleDir </> ".seihou-origin.json")
+          "{\"sourceUrl\":\"https://example.com/mods.git\"}"
+        origin <- detectArtifactOrigin projectRoot moduleDir
+        origin `shouldBe` RemoteOrigin "https://example.com/mods.git" "haskell-base" Nothing
+
+    it "falls back to a local origin when the metadata is malformed" $ do
+      withRoots $ \projectRoot installedRoot -> do
+        let moduleDir = installedRoot </> "haskell-base"
+        createDirectoryIfMissing True moduleDir
+        writeFile (moduleDir </> ".seihou-origin.json") "not json at all"
+        origin <- detectArtifactOrigin projectRoot moduleDir
+        origin `shouldBe` LocalOrigin "haskell-base"
+
+    it "falls back to a local origin when there is no metadata file" $ do
+      withRoots $ \projectRoot installedRoot -> do
+        let moduleDir = installedRoot </> "scratch-module"
+        createDirectoryIfMissing True moduleDir
+        origin <- detectArtifactOrigin projectRoot moduleDir
+        origin `shouldBe` LocalOrigin "scratch-module"
+
+    it "treats the project root itself as outside the project" $ do
+      withRoots $ \projectRoot _installedRoot -> do
+        origin <- detectArtifactOrigin projectRoot projectRoot
+        origin `shouldBe` LocalOrigin "project"
+
+    it "classifies a directory that does not exist without throwing" $ do
+      withRoots $ \projectRoot _installedRoot -> do
+        origin <- detectArtifactOrigin projectRoot (projectRoot </> ".seihou" </> "modules" </> "ghost")
+        origin `shouldBe` ProjectOrigin ".seihou/modules/ghost"
diff --git a/test/Seihou/Core/ArtifactRefSpec.hs b/test/Seihou/Core/ArtifactRefSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Seihou/Core/ArtifactRefSpec.hs
@@ -0,0 +1,141 @@
+module Seihou.Core.ArtifactRefSpec (tests) where
+
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import Seihou.Core.ArtifactRef
+import Seihou.Core.Types
+import System.Directory (createDirectoryIfMissing)
+import System.FilePath ((</>))
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Hspec
+import Test.Tasty
+import Test.Tasty.Hspec (testSpec)
+
+tests :: IO TestTree
+tests = testSpec "Seihou.Core.ArtifactRef" spec
+
+-- | A project root plus the three search paths seihou discovers through, in
+-- the same order as 'Seihou.Core.Module.defaultSearchPaths': the project's
+-- own modules, the developer's personal modules, and the install cache.
+data Roots = Roots
+  { projectRoot :: !FilePath,
+    projectModules :: !FilePath,
+    userModules :: !FilePath,
+    installed :: !FilePath
+  }
+  deriving stock (Generic)
+
+searchPathsOf :: Roots -> [FilePath]
+searchPathsOf roots = [roots ^. #projectModules, roots ^. #userModules, roots ^. #installed]
+
+withRoots :: (Roots -> IO a) -> IO a
+withRoots body =
+  withSystemTempDirectory "seihou-artifact-ref" $ \tmpDir -> do
+    let roots =
+          Roots
+            { projectRoot = tmpDir </> "project",
+              projectModules = tmpDir </> "project" </> ".seihou" </> "modules",
+              userModules = tmpDir </> "home" </> "seihou" </> "modules",
+              installed = tmpDir </> "home" </> "seihou" </> "installed"
+            }
+    mapM_ (createDirectoryIfMissing True) ((roots ^. #projectRoot) : searchPathsOf roots)
+    body roots
+
+-- | Create @<parent>/<name>/module.dhall@ and return the module directory.
+plantModule :: FilePath -> String -> IO FilePath
+plantModule parent name = do
+  let directory = parent </> name
+  createDirectoryIfMissing True directory
+  writeFile (directory </> "module.dhall") "{- fixture -}"
+  pure directory
+
+resolve :: Roots -> ArtifactOrigin -> IO (Either ArtifactRefError FilePath)
+resolve roots = resolveArtifactOrigin ((roots ^. #projectRoot)) (searchPathsOf roots) "module.dhall"
+
+remoteOrigin :: ArtifactOrigin
+remoteOrigin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules")
+
+spec :: Spec
+spec = do
+  describe "resolveArtifactOrigin" $ do
+    it "finds a remote-origin artifact in the install cache" $ do
+      withRoots $ \roots -> do
+        expected <- plantModule ((roots ^. #installed)) "haskell-base"
+        resolve roots remoteOrigin `shouldReturn` Right expected
+
+    it "lets a project-local copy shadow the installed one" $ do
+      withRoots $ \roots -> do
+        shadow <- plantModule ((roots ^. #projectModules)) "haskell-base"
+        _ <- plantModule ((roots ^. #installed)) "haskell-base"
+        resolve roots remoteOrigin `shouldReturn` Right shadow
+
+    it "finds a local-origin artifact in the personal module directory" $ do
+      withRoots $ \roots -> do
+        expected <- plantModule ((roots ^. #userModules)) "scratch"
+        resolve roots (LocalOrigin "scratch") `shouldReturn` Right expected
+
+    it "reports every probed directory in order when nothing matches" $ do
+      withRoots $ \roots -> do
+        result <- resolve roots remoteOrigin
+        result
+          `shouldBe` Left
+            ( ArtifactNotFoundLocally
+                remoteOrigin
+                [ (roots ^. #projectModules) </> "haskell-base",
+                  (roots ^. #userModules) </> "haskell-base",
+                  (roots ^. #installed) </> "haskell-base"
+                ]
+            )
+
+    it "ignores a directory that has no definition file" $ do
+      withRoots $ \roots -> do
+        createDirectoryIfMissing True ((roots ^. #projectModules) </> "haskell-base")
+        expected <- plantModule ((roots ^. #installed)) "haskell-base"
+        resolve roots remoteOrigin `shouldReturn` Right expected
+
+    it "resolves a project origin against the project root" $ do
+      withRoots $ \roots -> do
+        expected <- plantModule ((roots ^. #projectModules)) "docs"
+        resolve roots (ProjectOrigin ".seihou/modules/docs") `shouldReturn` Right expected
+
+    it "refuses to substitute an installed artifact for a missing project one" $ do
+      withRoots $ \roots -> do
+        _ <- plantModule ((roots ^. #installed)) "docs"
+        result <- resolve roots (ProjectOrigin ".seihou/modules/docs")
+        result
+          `shouldBe` Left
+            ( ProjectArtifactMissing
+                (ProjectOrigin ".seihou/modules/docs")
+                ((roots ^. #projectRoot) </> ".seihou" </> "modules" </> "docs")
+            )
+
+  describe "renderArtifactRefError" $ do
+    it "names the recorded URL, every probed directory, and the install remedy" $ do
+      withRoots $ \roots -> do
+        Left err <- resolve roots remoteOrigin
+        let message = renderArtifactRefError err
+        message `shouldSatisfy` T.isInfixOf "haskell-base"
+        message `shouldSatisfy` T.isInfixOf "https://github.com/shinzui/seihou-modules.git"
+        message `shouldSatisfy` T.isInfixOf "seihou install https://github.com/shinzui/seihou-modules.git"
+        mapM_
+          (\directory -> message `shouldSatisfy` T.isInfixOf (T.pack directory))
+          [ (roots ^. #projectModules) </> "haskell-base",
+            (roots ^. #userModules) </> "haskell-base",
+            (roots ^. #installed) </> "haskell-base"
+          ]
+
+    it "says a local-origin artifact has no upstream to fetch from" $ do
+      withRoots $ \roots -> do
+        Left err <- resolve roots (LocalOrigin "scratch")
+        let message = renderArtifactRefError err
+        message `shouldSatisfy` T.isInfixOf "no recorded upstream"
+        message `shouldSatisfy` not . T.isInfixOf "seihou install"
+
+    it "says a missing project artifact should have been committed" $ do
+      withRoots $ \roots -> do
+        Left err <- resolve roots (ProjectOrigin ".seihou/modules/docs")
+        let message = renderArtifactRefError err
+        message `shouldSatisfy` T.isInfixOf ".seihou/modules/docs"
+        message `shouldSatisfy` T.isInfixOf "committed"
diff --git a/test/Seihou/Core/BlueprintSpec.hs b/test/Seihou/Core/BlueprintSpec.hs
--- a/test/Seihou/Core/BlueprintSpec.hs
+++ b/test/Seihou/Core/BlueprintSpec.hs
@@ -1,8 +1,10 @@
 module Seihou.Core.BlueprintSpec (tests) where
 
+import Control.Lens (at, (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
-import Seihou.Core.Blueprint (checkBlueprintMigrations, validateBlueprintWith)
+import Seihou.Core.Blueprint (checkBlueprintLaunch, checkBlueprintMigrations, validateBlueprintWith)
 import Seihou.Core.Migration (BlueprintMigration (..))
 import Seihou.Core.Module (discoverRunnable)
 import Seihou.Core.Types
@@ -46,6 +48,7 @@
     Nothing
     []
     []
+    Nothing
 
 -- | Helpers to update individual 'Blueprint' fields without ambiguous
 -- record updates. Several @Blueprint@ fields collide by name with
@@ -53,44 +56,48 @@
 -- the ambiguity once and for all.
 withBlueprintName :: ModuleName -> Blueprint -> Blueprint
 withBlueprintName n b =
-  Blueprint n b.version b.description b.prompt b.vars b.prompts b.baseModules b.files b.allowedTools b.tags b.migrations
+  Blueprint n (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintVersion :: Maybe T.Text -> Blueprint -> Blueprint
 withBlueprintVersion v b =
-  Blueprint b.name v b.description b.prompt b.vars b.prompts b.baseModules b.files b.allowedTools b.tags b.migrations
+  Blueprint (b ^. #name) v (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintPrompt :: T.Text -> Blueprint -> Blueprint
 withBlueprintPrompt p b =
-  Blueprint b.name b.version b.description p b.vars b.prompts b.baseModules b.files b.allowedTools b.tags b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) p (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintVars :: [VarDecl] -> Blueprint -> Blueprint
 withBlueprintVars vs b =
-  Blueprint b.name b.version b.description b.prompt vs b.prompts b.baseModules b.files b.allowedTools b.tags b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) vs (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintPrompts :: [Prompt] -> Blueprint -> Blueprint
 withBlueprintPrompts ps b =
-  Blueprint b.name b.version b.description b.prompt b.vars ps b.baseModules b.files b.allowedTools b.tags b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) ps (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintBaseModules :: [Dependency] -> Blueprint -> Blueprint
 withBlueprintBaseModules ds b =
-  Blueprint b.name b.version b.description b.prompt b.vars b.prompts ds b.files b.allowedTools b.tags b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) ds (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintFiles :: [BlueprintFile] -> Blueprint -> Blueprint
 withBlueprintFiles fs b =
-  Blueprint b.name b.version b.description b.prompt b.vars b.prompts b.baseModules fs b.allowedTools b.tags b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) fs (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintAllowedTools :: Maybe [T.Text] -> Blueprint -> Blueprint
 withBlueprintAllowedTools at b =
-  Blueprint b.name b.version b.description b.prompt b.vars b.prompts b.baseModules b.files at b.tags b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) at (b ^. #tags) (b ^. #migrations) (b ^. #launch)
 
 withBlueprintTags :: [T.Text] -> Blueprint -> Blueprint
 withBlueprintTags ts b =
-  Blueprint b.name b.version b.description b.prompt b.vars b.prompts b.baseModules b.files b.allowedTools ts b.migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) ts (b ^. #migrations) (b ^. #launch)
 
 withBlueprintMigrations :: [BlueprintMigration] -> Blueprint -> Blueprint
 withBlueprintMigrations migrations b =
-  Blueprint b.name b.version b.description b.prompt b.vars b.prompts b.baseModules b.files b.allowedTools b.tags migrations
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) migrations (b ^. #launch)
 
+withBlueprintLaunch :: Maybe AgentLaunch -> Blueprint -> Blueprint
+withBlueprintLaunch launch b =
+  Blueprint (b ^. #name) (b ^. #version) (b ^. #description) (b ^. #prompt) (b ^. #vars) (b ^. #prompts) (b ^. #baseModules) (b ^. #files) (b ^. #allowedTools) (b ^. #tags) (b ^. #migrations) launch
+
 spec :: Spec
 spec = do
   describe "evalBlueprintFromFile (sample fixture)" $ do
@@ -99,15 +106,15 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right b -> do
-          b.name `shouldBe` ModuleName "sample-blueprint"
-          b.version `shouldBe` Just "0.1.0"
-          b.description `shouldBe` Just "Fixture blueprint for EP-29 tests"
-          T.isInfixOf "{{project.name}}" b.prompt `shouldBe` True
-          length b.vars `shouldBe` 2
-          b.tags `shouldBe` ["demo"]
-          b.baseModules `shouldBe` []
-          length b.files `shouldBe` 1
-          b.migrations
+          (b ^. #name) `shouldBe` ModuleName "sample-blueprint"
+          (b ^. #version) `shouldBe` Just "0.1.0"
+          (b ^. #description) `shouldBe` Just "Fixture blueprint for EP-29 tests"
+          T.isInfixOf "{{project.name}}" (b ^. #prompt) `shouldBe` True
+          length (b ^. #vars) `shouldBe` 2
+          (b ^. #tags) `shouldBe` ["demo"]
+          (b ^. #baseModules) `shouldBe` []
+          length (b ^. #files) `shouldBe` 1
+          (b ^. #migrations)
             `shouldBe` [ BlueprintMigration "1.0.0" "2.0.0" "Update {{project.name}} for the first library release.",
                          BlueprintMigration "2.5.0" "3.0.0" "Update {{project.name}} for the second library release."
                        ]
@@ -119,12 +126,60 @@
         result <- evalBlueprintFromFile path
         case result of
           Right b ->
-            b.migrations
+            (b ^. #migrations)
               `shouldBe` [ BlueprintMigration "1.0.0" "2.0.0" "first edge",
                            BlueprintMigration "2.5.0" "3.0.0" "second edge"
                          ]
           Left err -> expectationFailure ("Expected migrations to decode, got: " <> show err)
 
+    it "decodes a declared launch record" $ do
+      withSystemTempDirectory "seihou-blueprint-launch-decode" $ \tmpDir -> do
+        let path = tmpDir </> "blueprint.dhall"
+        writeFile path (sampleBlueprintWithLaunchDhall "launch-bp")
+        result <- evalBlueprintFromFile path
+        case result of
+          Right b ->
+            (b ^. #launch)
+              `shouldBe` Just
+                AgentLaunch
+                  { provider = Just "codex-cli",
+                    model = Just "gpt-5.6-terra",
+                    effort = Just "max",
+                    mode = Just "reserved"
+                  }
+          Left err -> expectationFailure ("Expected launch to decode, got: " <> show err)
+
+    -- Regression: blueprints authored against a schema pin that predates the
+    -- launch field must keep decoding. 'sampleBlueprintDhall' writes no
+    -- @launch@ key at all, so this exercises the decoder's 'withDefaults'.
+    it "decodes a blueprint with no launch field as Nothing" $ do
+      withSystemTempDirectory "seihou-blueprint-nolaunch-decode" $ \tmpDir -> do
+        let path = tmpDir </> "blueprint.dhall"
+        writeFile path (sampleBlueprintDhall "no-launch-bp")
+        result <- evalBlueprintFromFile path
+        case result of
+          Right b -> (b ^. #launch) `shouldBe` Nothing
+          Left err -> expectationFailure ("Expected blueprint to decode, got: " <> show err)
+
+    -- Regression: a launch record written against the older three-field
+    -- schema (provider/mode/model, no effort) must still decode.
+    it "decodes a launch record that predates the effort field" $ do
+      withSystemTempDirectory "seihou-blueprint-oldlaunch-decode" $ \tmpDir -> do
+        let path = tmpDir </> "blueprint.dhall"
+        writeFile path (sampleBlueprintWithLegacyLaunchDhall "legacy-launch-bp")
+        result <- evalBlueprintFromFile path
+        case result of
+          Right b ->
+            (b ^. #launch)
+              `shouldBe` Just
+                AgentLaunch
+                  { provider = Just "claude-cli",
+                    model = Just "claude-opus-4-8",
+                    effort = Nothing,
+                    mode = Nothing
+                  }
+          Left err -> expectationFailure ("Expected legacy launch to decode, got: " <> show err)
+
   describe "validateBlueprintWith (sample fixture)" $ do
     it "accepts the sample-blueprint fixture" $ do
       cwd <- getCurrentDirectory
@@ -132,7 +187,7 @@
       Right b <- evalBlueprintFromFile (baseDir </> "blueprint.dhall")
       result <- validateBlueprintWith [] baseDir b
       case result of
-        Right b' -> b'.name `shouldBe` "sample-blueprint"
+        Right b' -> (b' ^. #name) `shouldBe` "sample-blueprint"
         Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
   describe "validateBlueprintWith (rule-by-rule)" $ do
@@ -182,7 +237,7 @@
                   required = False,
                   validation = Nothing
                 }
-            bad = withBlueprintVars (goodBlueprint.vars ++ [dup]) goodBlueprint
+            bad = withBlueprintVars (goodBlueprint ^. #vars ++ [dup]) goodBlueprint
         result <- validateBlueprintWith [] tmpDir bad
         case result of
           Left (ValidationError _ errs) ->
@@ -273,7 +328,7 @@
       withSystemTempDirectory "seihou-test" $ \tmpDir -> do
         let bad =
               withBlueprintBaseModules
-                [Dependency {depModule = "nope-not-here", depVars = Map.empty}]
+                [Dependency {module_ = "nope-not-here", vars = Map.empty}]
                 goodBlueprint
         result <- validateBlueprintWith [tmpDir] tmpDir bad
         case result of
@@ -288,7 +343,7 @@
         writeFile (nestedDir </> "blueprint.dhall") (sampleBlueprintDhall "nested-bp")
         let bad =
               withBlueprintBaseModules
-                [Dependency {depModule = "nested-bp", depVars = Map.empty}]
+                [Dependency {module_ = "nested-bp", vars = Map.empty}]
                 goodBlueprint
         result <- validateBlueprintWith [tmpDir] tmpDir bad
         case result of
@@ -296,6 +351,45 @@
             hasError "resolves to a blueprint" errs `shouldBe` True
           other -> expectationFailure ("Expected ValidationError, got: " <> show other)
 
+    it "rejects a blank declared launch field" $ do
+      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
+        let bad =
+              withBlueprintLaunch
+                (Just AgentLaunch {provider = Just "   ", model = Nothing, effort = Nothing, mode = Nothing})
+                goodBlueprint
+        result <- validateBlueprintWith [] tmpDir bad
+        case result of
+          Left (ValidationError _ errs) ->
+            hasError "launch.provider, if specified, must not be empty" errs `shouldBe` True
+          other -> expectationFailure ("Expected ValidationError, got: " <> show other)
+
+    it "reports every blank declared launch field" $ do
+      let bad =
+            withBlueprintLaunch
+              (Just AgentLaunch {provider = Just "", model = Just " ", effort = Just "", mode = Just "\t"})
+              goodBlueprint
+      checkBlueprintLaunch bad
+        `shouldBe` [ "launch.provider, if specified, must not be empty",
+                     "launch.model, if specified, must not be empty",
+                     "launch.effort, if specified, must not be empty",
+                     "launch.mode, if specified, must not be empty"
+                   ]
+
+    it "accepts a fully populated launch record" $ do
+      withSystemTempDirectory "seihou-test" $ \tmpDir -> do
+        let bp =
+              withBlueprintLaunch
+                (Just AgentLaunch {provider = Just "claude-cli", model = Just "claude-opus-4-8", effort = Just "max", mode = Nothing})
+                goodBlueprint
+        checkBlueprintLaunch bp `shouldBe` []
+        result <- validateBlueprintWith [] tmpDir bp
+        case result of
+          Right _ -> pure ()
+          Left err -> expectationFailure ("Expected Right, got: " <> show err)
+
+    it "accepts a blueprint that declares no launch record" $
+      checkBlueprintLaunch goodBlueprint `shouldBe` []
+
   describe "discoverRunnable for blueprints" $ do
     it "finds a blueprint when only blueprint.dhall is present" $ do
       withSystemTempDirectory "seihou-test" $ \tmpDir -> do
@@ -305,7 +399,7 @@
         result <- discoverRunnable [tmpDir] "only-bp"
         case result of
           Right (RunnableBlueprint b dir) -> do
-            b.name `shouldBe` "only-bp"
+            (b ^. #name) `shouldBe` "only-bp"
             dir `shouldBe` bpDir
           other -> expectationFailure ("Expected RunnableBlueprint, got: " <> show other)
 
@@ -399,5 +493,34 @@
            "    [ { from = \"1.0.0\", to = \"2.0.0\", prompt = \"first edge\" }",
            "    , { from = \"2.5.0\", to = \"3.0.0\", prompt = \"second edge\" }",
            "    ]",
+           "}"
+         ]
+
+-- | A blueprint declaring all four launch fields.
+sampleBlueprintWithLaunchDhall :: T.Text -> String
+sampleBlueprintWithLaunchDhall n =
+  unlines $
+    init (lines (sampleBlueprintDhall n))
+      <> [ ", launch = Some",
+           "    { provider = Some \"codex-cli\"",
+           "    , model = Some \"gpt-5.6-terra\"",
+           "    , effort = Some \"max\"",
+           "    , mode = Some \"reserved\"",
+           "    }",
+           "}"
+         ]
+
+-- | A blueprint whose launch record uses only the three fields that existed
+-- before @effort@ was added, as an artifact authored against an older schema
+-- pin would.
+sampleBlueprintWithLegacyLaunchDhall :: T.Text -> String
+sampleBlueprintWithLegacyLaunchDhall n =
+  unlines $
+    init (lines (sampleBlueprintDhall n))
+      <> [ ", launch = Some",
+           "    { provider = Some \"claude-cli\"",
+           "    , mode = None Text",
+           "    , model = Some \"claude-opus-4-8\"",
+           "    }",
            "}"
          ]
diff --git a/test/Seihou/Core/CommandVarSpec.hs b/test/Seihou/Core/CommandVarSpec.hs
--- a/test/Seihou/Core/CommandVarSpec.hs
+++ b/test/Seihou/Core/CommandVarSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.CommandVarSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Effectful (runPureEff)
@@ -40,28 +42,28 @@
 
 withCondition :: Maybe Expr -> CommandVar -> CommandVar
 withCondition condition cv =
-  CommandVar cv.name cv.run cv.workDir condition cv.trim cv.maxBytes
+  CommandVar (cv ^. #name) (cv ^. #run) (cv ^. #workDir) condition (cv ^. #trim) (cv ^. #maxBytes)
 
 withTrim :: Bool -> CommandVar -> CommandVar
 withTrim trim cv =
-  CommandVar cv.name cv.run cv.workDir cv.condition trim cv.maxBytes
+  CommandVar (cv ^. #name) (cv ^. #run) (cv ^. #workDir) (cv ^. #condition) trim (cv ^. #maxBytes)
 
 withMaxBytes :: Maybe Natural -> CommandVar -> CommandVar
 withMaxBytes maxBytes cv =
-  CommandVar cv.name cv.run cv.workDir cv.condition cv.trim maxBytes
+  CommandVar (cv ^. #name) (cv ^. #run) (cv ^. #workDir) (cv ^. #condition) (cv ^. #trim) maxBytes
 
 commandVarName :: CommandVar -> VarName
-commandVarName cv = cv.name
+commandVarName cv = (cv ^. #name)
 
 commandVarRun :: CommandVar -> T.Text
-commandVarRun cv = cv.run
+commandVarRun cv = (cv ^. #run)
 
 mock :: T.Text -> ExitCode -> T.Text -> T.Text -> ProcessMock
 mock run exitCode stdoutText stderrText =
   ProcessMock
-    { mockCommand = "sh",
-      mockArgs = ["-c", run],
-      mockResult = (exitCode, stdoutText, stderrText)
+    { command = "sh",
+      args = ["-c", run],
+      result = (exitCode, stdoutText, stderrText)
     }
 
 runResolve ::
@@ -110,9 +112,9 @@
               ]
       case result of
         Right m -> do
-          fmap (.value) (Map.lookup "git.branch" m) `shouldBe` Just (VText "main")
-          fmap (.value) (Map.lookup "release.ready" m) `shouldBe` Just (VBool True)
-          fmap (.source) (Map.lookup "git.branch" m) `shouldBe` Just (FromCommand "git branch --show-current")
+          fmap (^. #value) (Map.lookup "git.branch" m) `shouldBe` Just (VText "main")
+          fmap (^. #value) (Map.lookup "release.ready" m) `shouldBe` Just (VBool True)
+          fmap (^. #source) (Map.lookup "git.branch" m) `shouldBe` Just (FromCommand "git branch --show-current")
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "does not override already-resolved config values" $ do
@@ -135,7 +137,7 @@
               [count]
               Map.empty
               [mock (commandVarRun count) ExitSuccess "42\n" ""]
-      fmap (fmap (.value) . Map.lookup "change.count") result `shouldBe` Right (Just (VInt 42))
+      fmap (fmap (^. #value) . Map.lookup "change.count") result `shouldBe` Right (Just (VInt 42))
 
     it "uses a text declaration for command-only prompt variables" $ do
       let branch = cmdVar "git.branch" "git branch --show-current"
@@ -145,7 +147,7 @@
               [branch]
               Map.empty
               [mock (commandVarRun branch) ExitSuccess "main\n" ""]
-      fmap (fmap (.value) . Map.lookup "git.branch") result `shouldBe` Right (Just (VText "main"))
+      fmap (fmap (^. #value) . Map.lookup "git.branch") result `shouldBe` Right (Just (VText "main"))
 
     it "preserves untrimmed output when trim is false" $ do
       let branch = withTrim False (cmdVar "git.branch" "git branch --show-current")
@@ -155,7 +157,7 @@
               [branch]
               Map.empty
               [mock (commandVarRun branch) ExitSuccess "main\n" ""]
-      fmap (fmap (.value) . Map.lookup "git.branch") result `shouldBe` Right (Just (VText "main\n"))
+      fmap (fmap (^. #value) . Map.lookup "git.branch") result `shouldBe` Right (Just (VText "main\n"))
 
     it "rejects output that exceeds maxBytes" $ do
       let branch = withMaxBytes (Just 3) (cmdVar "git.branch" "git branch --show-current")
diff --git a/test/Seihou/Core/ListSpec.hs b/test/Seihou/Core/ListSpec.hs
--- a/test/Seihou/Core/ListSpec.hs
+++ b/test/Seihou/Core/ListSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.ListSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.Module (DiscoveredModule (..), ModuleSource (..), discoverAllModules)
 import Seihou.Core.Types (ModuleLoadError (..))
@@ -44,7 +46,7 @@
         let paths = [tmp </> "project", userDir, tmp </> "installed"]
         result <- discoverAllModules paths
         length result `shouldBe` 1
-        (head result).discoveredSource `shouldBe` SourceUser
+        ((head result) ^. #source) `shouldBe` SourceUser
 
     it "tags sources correctly across paths" $ do
       withSystemTempDirectory "seihou-list-test" $ \tmp -> do
@@ -57,7 +59,7 @@
         let paths = [projectDir, tmp </> "user", installedDir]
         result <- discoverAllModules paths
         length result `shouldBe` 2
-        let srcs = map (.discoveredSource) result
+        let srcs = map (^. #source) result
         SourceProject `elem` srcs `shouldBe` True
         SourceInstalled `elem` srcs `shouldBe` True
 
@@ -70,7 +72,7 @@
         let paths = [tmp </> "project", userDir, tmp </> "installed"]
         result <- discoverAllModules paths
         length result `shouldBe` 1
-        case (head result).discoveredResult of
+        case (head result) ^. #result of
           Left _ -> pure ()
           Right _ -> expectationFailure "Expected Left for broken module"
 
diff --git a/test/Seihou/Core/MigrationSpec.hs b/test/Seihou/Core/MigrationSpec.hs
--- a/test/Seihou/Core/MigrationSpec.hs
+++ b/test/Seihou/Core/MigrationSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.MigrationSpec (tests) where
 
+import Control.Lens (to, (^.))
+import Data.Generics.Labels ()
 import Data.Text (Text)
 import Seihou.Core.Migration
   ( BlueprintMigration (..),
@@ -35,10 +37,10 @@
           r = planMigrationChain "demo" [m] (mkV "1.0.0") (mkV "2.0.0")
       case r of
         Right (Just plan) -> do
-          plan.planModule `shouldBe` "demo"
-          plan.planFrom `shouldBe` mkV "1.0.0"
-          plan.planTo `shouldBe` mkV "2.0.0"
-          plan.planSteps `shouldBe` [m]
+          (plan ^. #module_) `shouldBe` "demo"
+          (plan ^. #from) `shouldBe` mkV "1.0.0"
+          (plan ^. #to) `shouldBe` mkV "2.0.0"
+          (plan ^. #steps) `shouldBe` [m]
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "builds a two-edge plan in order regardless of declaration order" $ do
@@ -47,8 +49,8 @@
           r = planMigrationChain "demo" [m2, m1] (mkV "1.0.0") (mkV "3.0.0")
       case r of
         Right (Just plan) -> do
-          plan.planSteps `shouldBe` [m1, m2]
-          plan.planTo `shouldBe` mkV "3.0.0"
+          (plan ^. #steps) `shouldBe` [m1, m2]
+          (plan ^. #to) `shouldBe` mkV "3.0.0"
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     -- Live-tree master-plan fixture (manifest=0.1.0, target=0.3.0,
@@ -59,18 +61,18 @@
           r = planMigrationChain "demo" [m] (mkV "0.1.0") (mkV "0.3.0")
       case r of
         Right (Just plan) -> do
-          plan.planSteps `shouldBe` [m]
-          plan.planFrom `shouldBe` mkV "0.1.0"
-          plan.planTo `shouldBe` mkV "0.3.0"
+          (plan ^. #steps) `shouldBe` [m]
+          (plan ^. #from) `shouldBe` mkV "0.1.0"
+          (plan ^. #to) `shouldBe` mkV "0.3.0"
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "yields an empty-steps plan when no declared migration falls in the window" $ do
       let r = planMigrationChain "demo" [] (mkV "0.1.3") (mkV "0.3.0")
       case r of
         Right (Just plan) -> do
-          plan.planSteps `shouldBe` []
-          plan.planFrom `shouldBe` mkV "0.1.3"
-          plan.planTo `shouldBe` mkV "0.3.0"
+          (plan ^. #steps) `shouldBe` []
+          (plan ^. #from) `shouldBe` mkV "0.1.3"
+          (plan ^. #to) `shouldBe` mkV "0.3.0"
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "reports MigrationVersionUnparseable when a from string is malformed" $ do
@@ -98,8 +100,8 @@
           r = planMigrationChain "demo" [stale, live] (mkV "1.0.0") (mkV "2.0.0")
       case r of
         Right (Just plan) -> do
-          plan.planSteps `shouldBe` [live]
-          plan.planTo `shouldBe` mkV "2.0.0"
+          (plan ^. #steps) `shouldBe` [live]
+          (plan ^. #to) `shouldBe` mkV "2.0.0"
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "treats version equality with trailing zeros consistently" $ do
@@ -118,9 +120,9 @@
           r = planMigrationChain "foo" [early, late] (mkV "0.2") (mkV "0.6")
       case r of
         Right (Just plan) -> do
-          plan.planFrom `shouldBe` mkV "0.2"
-          plan.planTo `shouldBe` mkV "0.6"
-          plan.planSteps `shouldBe` [early, late]
+          (plan ^. #from) `shouldBe` mkV "0.2"
+          (plan ^. #to) `shouldBe` mkV "0.6"
+          (plan ^. #steps) `shouldBe` [early, late]
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "skips migrations that overshoot the supplied target" $ do
@@ -128,8 +130,8 @@
           r = planMigrationChain "demo" [m] (mkV "0.4") (mkV "0.6")
       case r of
         Right (Just plan) -> do
-          plan.planSteps `shouldBe` []
-          plan.planTo `shouldBe` mkV "0.6"
+          (plan ^. #steps) `shouldBe` []
+          (plan ^. #to) `shouldBe` mkV "0.6"
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "skips overlapping migrations once the cursor has advanced past them" $ do
@@ -138,16 +140,16 @@
           r = planMigrationChain "demo" [big, small] (mkV "0.2") (mkV "0.5")
       case r of
         Right (Just plan) ->
-          plan.planSteps `shouldBe` [big]
+          (plan ^. #steps) `shouldBe` [big]
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "empty migrations list with installed != target yields empty-steps plan with target" $ do
       let r = planMigrationChain "demo" [] (mkV "0.1") (mkV "0.3")
       case r of
         Right (Just plan) -> do
-          plan.planSteps `shouldBe` []
-          plan.planFrom `shouldBe` mkV "0.1"
-          plan.planTo `shouldBe` mkV "0.3"
+          (plan ^. #steps) `shouldBe` []
+          (plan ^. #from) `shouldBe` mkV "0.1"
+          (plan ^. #to) `shouldBe` mkV "0.3"
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
     it "edges with `to == target` are picked" $ do
@@ -155,7 +157,7 @@
           r = planMigrationChain "demo" [m] (mkV "0.2") (mkV "0.3")
       case r of
         Right (Just plan) ->
-          plan.planSteps `shouldBe` [m]
+          (plan ^. #steps) `shouldBe` [m]
         other -> expectationFailure ("Expected Right (Just ...), got: " <> show other)
 
   describe "planBlueprintMigrationChain" $ do
@@ -165,10 +167,10 @@
           result = planBlueprintMigrationChain "demo" [late, early] (mkV "1.0.0") (mkV "3.0.0")
       case result of
         Right (Just plan) -> do
-          plan.blueprintPlanName `shouldBe` "demo"
-          plan.blueprintPlanFrom `shouldBe` mkV "1.0.0"
-          plan.blueprintPlanTo `shouldBe` mkV "3.0.0"
-          plan.blueprintPlanSteps `shouldBe` [early, late]
+          (plan ^. #name) `shouldBe` "demo"
+          (plan ^. #from) `shouldBe` mkV "1.0.0"
+          (plan ^. #to) `shouldBe` mkV "3.0.0"
+          (plan ^. #steps) `shouldBe` [early, late]
         other -> expectationFailure ("Expected ordered blueprint plan, got: " <> show other)
 
     it "returns Nothing for an equal version window" $ do
@@ -196,7 +198,7 @@
       let migration = BlueprintMigration "1.0.0" "3.0.0" "too far"
           result = planBlueprintMigrationChain "demo" [migration] (mkV "1.0.0") (mkV "2.0.0")
       case result of
-        Right (Just plan) -> plan.blueprintPlanSteps `shouldBe` []
+        Right (Just plan) -> (plan ^. #steps) `shouldBe` []
         other -> expectationFailure ("Expected empty blueprint plan, got: " <> show other)
 
 -- ---------------------------------------------------------------------------
diff --git a/test/Seihou/Core/ModuleSpec.hs b/test/Seihou/Core/ModuleSpec.hs
--- a/test/Seihou/Core/ModuleSpec.hs
+++ b/test/Seihou/Core/ModuleSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.ModuleSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.Module (discoverModule, loadModule, validateModule)
 import Seihou.Core.Types
@@ -49,13 +51,13 @@
 
 -- | Helpers to update Module fields without ambiguous record updates.
 withModuleName :: ModuleName -> Module -> Module
-withModuleName n m = Module n m.version m.description m.vars m.exports m.prompts m.steps m.commands m.dependencies m.removal m.migrations
+withModuleName n m = Module n (m ^. #version) (m ^. #description) (m ^. #vars) (m ^. #exports) (m ^. #prompts) (m ^. #steps) (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 withModuleVars :: [VarDecl] -> Module -> Module
-withModuleVars v m = Module m.name m.version m.description v m.exports m.prompts m.steps m.commands m.dependencies m.removal m.migrations
+withModuleVars v m = Module (m ^. #name) (m ^. #version) (m ^. #description) v (m ^. #exports) (m ^. #prompts) (m ^. #steps) (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 withModulePrompts :: [Prompt] -> Module -> Module
-withModulePrompts p m = Module m.name m.version m.description m.vars m.exports p m.steps m.commands m.dependencies m.removal m.migrations
+withModulePrompts p m = Module (m ^. #name) (m ^. #version) (m ^. #description) (m ^. #vars) (m ^. #exports) p (m ^. #steps) (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 hasError :: T.Text -> [T.Text] -> Bool
 hasError needle = any (T.isInfixOf needle)
@@ -75,7 +77,7 @@
       result <- discoverModule ["/nonexistent/path"] "no-such-module"
       case result of
         Left (ModuleNotFound name paths) -> do
-          name.unModuleName `shouldBe` "no-such-module"
+          (name ^. #unModuleName) `shouldBe` "no-such-module"
           paths `shouldBe` ["/nonexistent/path"]
         Left other -> expectationFailure ("Expected ModuleNotFound, got: " <> show other)
         Right _ -> expectationFailure "Expected Left, got Right"
@@ -100,7 +102,7 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         result <- validateModule tmpDir goodModule
         case result of
-          Right m -> m.name `shouldBe` "test-module"
+          Right m -> (m ^. #name) `shouldBe` "test-module"
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
     it "rejects a bad module name" $ do
@@ -161,9 +163,9 @@
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let bad =
-              goodModule
-                { exports = [VarExport {var = "nonexistent", alias = Nothing}]
-                }
+              ( goodModule
+                  & #exports .~ [VarExport {var = "nonexistent", alias = Nothing}]
+              )
         result <- validateModule tmpDir bad
         case result of
           Left (ValidationError _ errs) ->
@@ -177,9 +179,8 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let bad =
               goodModule
-                { steps =
-                    [Step Template "README.md.tpl" "../etc/passwd" Nothing Nothing]
-                }
+                & #steps
+                  .~ [Step Template "README.md.tpl" "../etc/passwd" Nothing Nothing]
         result <- validateModule tmpDir bad
         case result of
           Left (ValidationError _ errs) ->
@@ -193,9 +194,8 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let bad =
               goodModule
-                { steps =
-                    [Step Template "README.md.tpl" "/etc/passwd" Nothing Nothing]
-                }
+                & #steps
+                  .~ [Step Template "README.md.tpl" "/etc/passwd" Nothing Nothing]
         result <- validateModule tmpDir bad
         case result of
           Left (ValidationError _ errs) ->
@@ -209,12 +209,11 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let dotted =
               goodModule
-                { steps =
-                    [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing]
-                }
+                & #steps
+                  .~ [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing]
         result <- validateModule tmpDir dotted
         case result of
-          Right m -> m.name `shouldBe` "test-module"
+          Right m -> (m ^. #name) `shouldBe` "test-module"
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
     it "rejects destination referencing undeclared variable" $ do
@@ -223,9 +222,8 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let bad =
               goodModule
-                { steps =
-                    [Step Template "README.md.tpl" "src/{{unknown}}/Main.hs" Nothing Nothing]
-                }
+                & #steps
+                  .~ [Step Template "README.md.tpl" "src/{{unknown}}/Main.hs" Nothing Nothing]
         result <- validateModule tmpDir bad
         case result of
           Left (ValidationError _ errs) ->
@@ -263,9 +261,9 @@
       result <- loadModule searchPaths "haskell-base"
       case result of
         Right m -> do
-          m.name `shouldBe` "haskell-base"
-          length (m.vars) `shouldBe` 3
-          length (m.steps) `shouldBe` 5
+          (m ^. #name) `shouldBe` "haskell-base"
+          length (m ^. #vars) `shouldBe` 3
+          length (m ^. #steps) `shouldBe` 5
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
 
     it "returns ModuleNotFound for nonexistent module" $ do
diff --git a/test/Seihou/Core/RegistrySpec.hs b/test/Seihou/Core/RegistrySpec.hs
--- a/test/Seihou/Core/RegistrySpec.hs
+++ b/test/Seihou/Core/RegistrySpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.RegistrySpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.List (isInfixOf)
 import Data.Text (Text)
 import Seihou.Core.Registry
@@ -57,16 +59,16 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right reg -> do
-            reg.repoName `shouldBe` "Haskell Templates"
-            reg.repoDescription `shouldBe` Just "A collection of Haskell project templates"
-            length reg.modules `shouldBe` 2
-            let (e1 : e2 : _) = reg.modules
-            e1.name `shouldBe` ModuleName "haskell-base"
-            e1.path `shouldBe` "modules/haskell-base"
-            e1.description `shouldBe` Just "Minimal Haskell project with cabal"
-            e1.tags `shouldBe` ["haskell", "starter"]
-            e2.name `shouldBe` ModuleName "nix-flake"
-            e2.tags `shouldBe` ["nix"]
+            (reg ^. #repoName) `shouldBe` "Haskell Templates"
+            (reg ^. #repoDescription) `shouldBe` Just "A collection of Haskell project templates"
+            length (reg ^. #modules) `shouldBe` 2
+            let (e1 : e2 : _) = (reg ^. #modules)
+            (e1 ^. #name) `shouldBe` ModuleName "haskell-base"
+            (e1 ^. #path) `shouldBe` "modules/haskell-base"
+            (e1 ^. #description) `shouldBe` Just "Minimal Haskell project with cabal"
+            (e1 ^. #tags) `shouldBe` ["haskell", "starter"]
+            (e2 ^. #name) `shouldBe` ModuleName "nix-flake"
+            (e2 ^. #tags) `shouldBe` ["nix"]
 
     it "decodes a registry with an empty module list" $ do
       withSystemTempDirectory "seihou-registry-test" $ \tmpDir -> do
@@ -80,9 +82,9 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right reg -> do
-            reg.repoName `shouldBe` "Empty Collection"
-            reg.repoDescription `shouldBe` Nothing
-            reg.modules `shouldBe` []
+            (reg ^. #repoName) `shouldBe` "Empty Collection"
+            (reg ^. #repoDescription) `shouldBe` Nothing
+            (reg ^. #modules) `shouldBe` []
 
     it "decodes a registry with no description" $ do
       withSystemTempDirectory "seihou-registry-test" $ \tmpDir -> do
@@ -103,10 +105,10 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right reg -> do
-            reg.repoDescription `shouldBe` Nothing
-            let (e1 : _) = reg.modules
-            e1.description `shouldBe` Nothing
-            e1.tags `shouldBe` []
+            (reg ^. #repoDescription) `shouldBe` Nothing
+            let (e1 : _) = (reg ^. #modules)
+            (e1 ^. #description) `shouldBe` Nothing
+            (e1 ^. #tags) `shouldBe` []
 
     it "returns RegistryEvalError for malformed registry (missing required field)" $ do
       withSystemTempDirectory "seihou-registry-test" $ \tmpDir -> do
@@ -134,7 +136,7 @@
         writeRegistryFile tmpDir
         result <- discoverRepoContents evalRegistryFromFile tmpDir
         case result of
-          MultiModule reg -> reg.repoName `shouldBe` "Test Registry"
+          MultiModule reg -> (reg ^. #repoName) `shouldBe` "Test Registry"
           other -> expectationFailure ("Expected MultiModule, got: " <> show other)
 
     it "returns SingleModule when only module.dhall exists" $ do
@@ -151,7 +153,7 @@
         writeMinimalModuleDhall (tmpDir </> "module.dhall")
         result <- discoverRepoContents evalRegistryFromFile tmpDir
         case result of
-          MultiModule reg -> reg.repoName `shouldBe` "Test Registry"
+          MultiModule reg -> (reg ^. #repoName) `shouldBe` "Test Registry"
           other -> expectationFailure ("Expected MultiModule (registry takes precedence), got: " <> show other)
 
     it "returns EmptyRepo when neither file exists" $ do
@@ -249,11 +251,11 @@
                 }
             lookups = [(ModuleEntry, ModuleName "mod-a", Just "1.0.0")]
         report <- validateRegistryFull tmpDir reg lookups
-        report.reportIssues `shouldBe` []
-        report.reportModuleCount `shouldBe` 1
-        report.reportRecipeCount `shouldBe` 0
-        report.reportBlueprintCount `shouldBe` 0
-        report.reportPromptCount `shouldBe` 0
+        (report ^. #issues) `shouldBe` []
+        (report ^. #moduleCount) `shouldBe` 1
+        (report ^. #recipeCount) `shouldBe` 0
+        (report ^. #blueprintCount) `shouldBe` 0
+        (report ^. #promptCount) `shouldBe` 0
 
     it "flags a SyncMissing entry as a single VersionMismatch" $ do
       withSystemTempDirectory "seihou-validate-full" $ \tmpDir -> do
@@ -270,8 +272,8 @@
                 }
             lookups = [(ModuleEntry, ModuleName "mod-a", Just "1.0.0")]
         report <- validateRegistryFull tmpDir reg lookups
-        case report.reportIssues of
-          [VersionMismatch d] -> d.diffStatus `shouldBe` SyncMissing
+        case report ^. #issues of
+          [VersionMismatch d] -> (d ^. #status) `shouldBe` SyncMissing
           other -> expectationFailure ("expected one VersionMismatch SyncMissing, got: " <> show other)
 
     it "flags a SyncStale entry as a single VersionMismatch carrying the new version" $ do
@@ -289,8 +291,8 @@
                 }
             lookups = [(ModuleEntry, ModuleName "mod-a", Just "2.0.0")]
         report <- validateRegistryFull tmpDir reg lookups
-        case report.reportIssues of
-          [VersionMismatch d] -> d.diffStatus `shouldBe` SyncStale "2.0.0"
+        case report ^. #issues of
+          [VersionMismatch d] -> (d ^. #status) `shouldBe` SyncStale "2.0.0"
           other -> expectationFailure ("expected one VersionMismatch SyncStale, got: " <> show other)
 
     it "flags an invalid module name as a StructuralError" $ do
@@ -308,7 +310,7 @@
                 }
             lookups = [(ModuleEntry, ModuleName "Bad_Name", Nothing)]
         report <- validateRegistryFull tmpDir reg lookups
-        let structurals = [msg | StructuralError msg <- report.reportIssues]
+        let structurals = [msg | StructuralError msg <- report ^. #issues]
         any ("must match" `isInfixOf`) (map show structurals) `shouldBe` True
 
     it "flags an unsafe path with .. as a StructuralError" $ do
@@ -323,7 +325,7 @@
                   prompts = []
                 }
         report <- validateRegistryFull tmpDir reg []
-        let structurals = [msg | StructuralError msg <- report.reportIssues]
+        let structurals = [msg | StructuralError msg <- report ^. #issues]
         any ("must not contain" `isInfixOf`) (map show structurals) `shouldBe` True
 
     it "lists structural issues before version issues when both are present" $ do
@@ -350,11 +352,11 @@
                 (ModuleEntry, ModuleName "stale", Just "2.0.0")
               ]
         report <- validateRegistryFull tmpDir reg lookups
-        length report.reportIssues `shouldBe` 2
-        case report.reportIssues of
+        length (report ^. #issues) `shouldBe` 2
+        case report ^. #issues of
           [StructuralError msg, VersionMismatch d] -> do
             ("missing module.dhall" `isInfixOf` show msg) `shouldBe` True
-            d.diffStatus `shouldBe` SyncStale "2.0.0"
+            (d ^. #status) `shouldBe` SyncStale "2.0.0"
           other ->
             expectationFailure
               ("expected [StructuralError, VersionMismatch], got: " <> show other)
@@ -403,18 +405,18 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right reg -> do
-            length reg.modules `shouldBe` 1
-            length reg.recipes `shouldBe` 1
-            length reg.blueprints `shouldBe` 1
-            length reg.prompts `shouldBe` 1
-            let (bp : _) = reg.blueprints
-            bp.name `shouldBe` ModuleName "bp-one"
-            bp.version `shouldBe` Just "0.1.0"
-            bp.tags `shouldBe` ["agent"]
-            let (prompt : _) = reg.prompts
-            prompt.name `shouldBe` ModuleName "prompt-one"
-            prompt.version `shouldBe` Just "0.2.0"
-            prompt.tags `shouldBe` ["review"]
+            length (reg ^. #modules) `shouldBe` 1
+            length (reg ^. #recipes) `shouldBe` 1
+            length (reg ^. #blueprints) `shouldBe` 1
+            length (reg ^. #prompts) `shouldBe` 1
+            let (bp : _) = (reg ^. #blueprints)
+            (bp ^. #name) `shouldBe` ModuleName "bp-one"
+            (bp ^. #version) `shouldBe` Just "0.1.0"
+            (bp ^. #tags) `shouldBe` ["agent"]
+            let (prompt : _) = (reg ^. #prompts)
+            (prompt ^. #name) `shouldBe` ModuleName "prompt-one"
+            (prompt ^. #version) `shouldBe` Just "0.2.0"
+            (prompt ^. #tags) `shouldBe` ["review"]
 
     it "decodes a pre-EP-33 registry (no blueprints or prompts fields) with empty lists" $ do
       withSystemTempDirectory "seihou-registry-bp-compat" $ \tmpDir -> do
@@ -435,9 +437,9 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right reg -> do
-            reg.recipes `shouldBe` []
-            reg.blueprints `shouldBe` []
-            reg.prompts `shouldBe` []
+            (reg ^. #recipes) `shouldBe` []
+            (reg ^. #blueprints) `shouldBe` []
+            (reg ^. #prompts) `shouldBe` []
 
     it "rejects an invalid blueprint name" $ do
       withSystemTempDirectory "seihou-validate-bp" $ \tmpDir -> do
@@ -534,7 +536,7 @@
         any ("appears as both a module and a blueprint" `isInfixOf`) messages `shouldBe` True
         any ("appears as both a recipe and a blueprint" `isInfixOf`) messages `shouldBe` True
 
-    it "computeRegistrySync classifies blueprint entries with diffKind = BlueprintEntry" $ do
+    it "computeRegistrySync classifies blueprint entries with kind = BlueprintEntry" $ do
       let reg =
             Registry
               { repoName = "Test",
@@ -548,16 +550,16 @@
               }
           lookups = [(BlueprintEntry, ModuleName "bp-stale", Just "0.2.0")]
           SyncReport diffs updated = computeRegistrySync reg lookups
-          kinds = [diff_ | SyncDiff {diffKind = diff_} <- diffs]
-          statuses = [s | SyncDiff {diffStatus = s} <- diffs]
+          kinds = [diff_ | SyncDiff {kind = diff_} <- diffs]
+          statuses = [s | SyncDiff {status = s} <- diffs]
       kinds `shouldBe` [BlueprintEntry]
       statuses `shouldBe` [SyncStale "0.2.0"]
-      let updatedVersion = case updated.blueprints of
+      let updatedVersion = case updated ^. #blueprints of
             (RegistryEntry _ v _ _ _ : _) -> v
             _ -> Nothing
       updatedVersion `shouldBe` Just ("0.2.0" :: Text)
 
-    it "validateRegistryFull populates reportBlueprintCount" $ do
+    it "validateRegistryFull populates blueprintCount" $ do
       withSystemTempDirectory "seihou-validate-bp-count" $ \tmpDir -> do
         createDirectoryIfMissing True (tmpDir </> "bp-a")
         writeMinimalBlueprintDhall (tmpDir </> "bp-a" </> "blueprint.dhall")
@@ -580,8 +582,8 @@
                 (BlueprintEntry, ModuleName "bp-b", Just "1.0.0")
               ]
         report <- validateRegistryFull tmpDir reg lookups
-        report.reportBlueprintCount `shouldBe` 2
-        report.reportIssues `shouldBe` []
+        (report ^. #blueprintCount) `shouldBe` 2
+        (report ^. #issues) `shouldBe` []
 
   describe "prompts in registries" $ do
     it "rejects an invalid prompt name" $ do
@@ -661,7 +663,7 @@
         any ("appears as both a recipe and a prompt" `isInfixOf`) messages `shouldBe` True
         any ("appears as both a blueprint and a prompt" `isInfixOf`) messages `shouldBe` True
 
-    it "computeRegistrySync classifies prompt entries with diffKind = PromptEntry" $ do
+    it "computeRegistrySync classifies prompt entries with kind = PromptEntry" $ do
       let reg =
             Registry
               { repoName = "Test",
@@ -675,16 +677,16 @@
               }
           lookups = [(PromptEntry, ModuleName "prompt-stale", Just "0.2.0")]
           SyncReport diffs updated = computeRegistrySync reg lookups
-          kinds = [diff_ | SyncDiff {diffKind = diff_} <- diffs]
-          statuses = [s | SyncDiff {diffStatus = s} <- diffs]
+          kinds = [diff_ | SyncDiff {kind = diff_} <- diffs]
+          statuses = [s | SyncDiff {status = s} <- diffs]
       kinds `shouldBe` [PromptEntry]
       statuses `shouldBe` [SyncStale "0.2.0"]
-      let updatedVersion = case updated.prompts of
+      let updatedVersion = case updated ^. #prompts of
             (RegistryEntry _ v _ _ _ : _) -> v
             _ -> Nothing
       updatedVersion `shouldBe` Just ("0.2.0" :: Text)
 
-    it "validateRegistryFull populates reportPromptCount" $ do
+    it "validateRegistryFull populates promptCount" $ do
       withSystemTempDirectory "seihou-validate-prompt-count" $ \tmpDir -> do
         createDirectoryIfMissing True (tmpDir </> "prompt-a")
         writeMinimalPromptDhall (tmpDir </> "prompt-a" </> "prompt.dhall")
@@ -707,8 +709,8 @@
                 (PromptEntry, ModuleName "prompt-b", Just "1.0.0")
               ]
         report <- validateRegistryFull tmpDir reg lookups
-        report.reportPromptCount `shouldBe` 2
-        report.reportIssues `shouldBe` []
+        (report ^. #promptCount) `shouldBe` 2
+        (report ^. #issues) `shouldBe` []
 
   describe "discoverRepoContents and blueprints" $ do
     it "returns SingleBlueprint when only blueprint.dhall is present" $ do
diff --git a/test/Seihou/Core/RegistrySyncSpec.hs b/test/Seihou/Core/RegistrySyncSpec.hs
--- a/test/Seihou/Core/RegistrySyncSpec.hs
+++ b/test/Seihou/Core/RegistrySyncSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.RegistrySyncSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Maybe (isJust, mapMaybe)
 import Data.Text (Text)
 import Data.Text qualified as T
@@ -27,52 +29,50 @@
     let entry = mkEntry "alpha" Nothing
         reg = mkReg [entry] []
         report = computeRegistrySync reg [(ModuleEntry, ModuleName "alpha", Just "1.0.0")]
-    map (.diffStatus) report.syncDiffs `shouldBe` [SyncMissing]
-    map (.diffNew) report.syncDiffs `shouldBe` [Just "1.0.0"]
-    (head report.syncUpdated.modules).version `shouldBe` Just "1.0.0"
+    map (^. #status) (report ^. #diffs) `shouldBe` [SyncMissing]
+    map (^. #new) (report ^. #diffs) `shouldBe` [Just "1.0.0"]
+    ((head (report ^. #updated . #modules)) ^. #version) `shouldBe` Just "1.0.0"
 
   it "classifies SyncStale when registry and disk versions differ" $ do
     let entry = mkEntry "alpha" (Just "0.1.0")
         reg = mkReg [entry] []
         report = computeRegistrySync reg [(ModuleEntry, ModuleName "alpha", Just "1.0.0")]
-    map (.diffStatus) report.syncDiffs `shouldBe` [SyncStale "1.0.0"]
-    map (.diffOld) report.syncDiffs `shouldBe` [Just "0.1.0"]
-    map (.diffNew) report.syncDiffs `shouldBe` [Just "1.0.0"]
-    (head report.syncUpdated.modules).version `shouldBe` Just "1.0.0"
+    map (^. #status) (report ^. #diffs) `shouldBe` [SyncStale "1.0.0"]
+    map (^. #old) (report ^. #diffs) `shouldBe` [Just "0.1.0"]
+    map (^. #new) (report ^. #diffs) `shouldBe` [Just "1.0.0"]
+    ((head (report ^. #updated . #modules)) ^. #version) `shouldBe` Just "1.0.0"
 
   it "classifies SyncInSync when registry and disk versions match" $ do
     let entry = mkEntry "alpha" (Just "1.0.0")
         reg = mkReg [entry] []
         report = computeRegistrySync reg [(ModuleEntry, ModuleName "alpha", Just "1.0.0")]
-    map (.diffStatus) report.syncDiffs `shouldBe` [SyncInSync]
-    (head report.syncUpdated.modules).version `shouldBe` Just "1.0.0"
+    map (^. #status) (report ^. #diffs) `shouldBe` [SyncInSync]
+    ((head (report ^. #updated . #modules)) ^. #version) `shouldBe` Just "1.0.0"
 
   it "classifies SyncInSync when registry and disk are both Nothing" $ do
     let entry = mkEntry "alpha" Nothing
         reg = mkReg [entry] []
         report = computeRegistrySync reg [(ModuleEntry, ModuleName "alpha", Nothing)]
-    map (.diffStatus) report.syncDiffs `shouldBe` [SyncInSync]
-    (head report.syncUpdated.modules).version `shouldBe` Nothing
+    map (^. #status) (report ^. #diffs) `shouldBe` [SyncInSync]
+    ((head (report ^. #updated . #modules)) ^. #version) `shouldBe` Nothing
 
   it "classifies SyncOrphan when the entry has no lookup (module.dhall absent/unreadable)" $ do
     let entry = mkEntry "alpha" (Just "1.0.0")
         reg = mkReg [entry] []
         report = computeRegistrySync reg []
-    map (.diffStatus) report.syncDiffs `shouldBe` [SyncOrphan]
+    map (^. #status) (report ^. #diffs) `shouldBe` [SyncOrphan]
     -- Orphan: version left as-is
-    (head report.syncUpdated.modules).version `shouldBe` Just "1.0.0"
+    ((head (report ^. #updated . #modules)) ^. #version) `shouldBe` Just "1.0.0"
 
   it "preserves registry order in the diff output" $ do
     let reg =
-          ( mkReg
-              [ mkEntry "alpha" Nothing,
-                mkEntry "beta" (Just "0.1.0"),
-                mkEntry "gamma" (Just "2.0.0")
-              ]
-              [mkEntry "lib-one" Nothing]
-          )
-            { prompts = [mkEntry "review" Nothing]
-            }
+          mkReg
+            [ mkEntry "alpha" Nothing,
+              mkEntry "beta" (Just "0.1.0"),
+              mkEntry "gamma" (Just "2.0.0")
+            ]
+            [mkEntry "lib-one" Nothing]
+            & #prompts .~ [mkEntry "review" Nothing]
         lookups =
           [ (ModuleEntry, ModuleName "alpha", Just "1.0.0"),
             (ModuleEntry, ModuleName "beta", Just "0.2.0"),
@@ -81,23 +81,23 @@
             (PromptEntry, ModuleName "review", Just "0.4.0")
           ]
         report = computeRegistrySync reg lookups
-    map (.diffName) report.syncDiffs
+    map (^. #name) (report ^. #diffs)
       `shouldBe` [ ModuleName "alpha",
                    ModuleName "beta",
                    ModuleName "gamma",
                    ModuleName "lib-one",
                    ModuleName "review"
                  ]
-    map (.diffKind) report.syncDiffs
+    map (^. #kind) (report ^. #diffs)
       `shouldBe` [ModuleEntry, ModuleEntry, ModuleEntry, RecipeEntry, PromptEntry]
-    map (.diffStatus) report.syncDiffs
+    map (^. #status) (report ^. #diffs)
       `shouldBe` [SyncMissing, SyncStale "0.2.0", SyncInSync, SyncMissing, SyncMissing]
 
   it "returns an empty report for an empty registry" $ do
     let reg = mkReg [] []
         report = computeRegistrySync reg []
-    report.syncDiffs `shouldBe` []
-    report.syncUpdated `shouldBe` reg
+    (report ^. #diffs) `shouldBe` []
+    (report ^. #updated) `shouldBe` reg
 
   it "distinguishes module and recipe entries with the same name in lookups" $ do
     -- Module and recipe namespaces share a validation check,
@@ -111,35 +111,35 @@
             (RecipeEntry, ModuleName "beta", Just "2.0.0")
           ]
         report = computeRegistrySync reg lookups
-    map (.diffNew) report.syncDiffs `shouldBe` [Just "1.0.0", Just "2.0.0"]
+    map (^. #new) (report ^. #diffs) `shouldBe` [Just "1.0.0", Just "2.0.0"]
 
   describe "formatDriftWarning" $ do
     it "produces a warning for a stale entry" $ do
       let reg = mkReg [mkEntry "alpha" (Just "0.1.0")] []
           lookups = [(ModuleEntry, ModuleName "alpha", Just "1.0.0")]
           report = computeRegistrySync reg lookups
-          warnings = mapMaybe formatDriftWarning report.syncDiffs
+          warnings = mapMaybe formatDriftWarning (report ^. #diffs)
       length warnings `shouldBe` 1
-      isJust (formatDriftWarning (head report.syncDiffs)) `shouldBe` True
+      isJust (formatDriftWarning (head (report ^. #diffs))) `shouldBe` True
 
     it "produces no warnings when all entries are in sync" $ do
       let reg = mkReg [mkEntry "alpha" (Just "1.0.0")] []
           lookups = [(ModuleEntry, ModuleName "alpha", Just "1.0.0")]
           report = computeRegistrySync reg lookups
-          warnings = mapMaybe formatDriftWarning report.syncDiffs
+          warnings = mapMaybe formatDriftWarning (report ^. #diffs)
       warnings `shouldBe` []
 
     it "produces no warnings for orphan entries (handled by validateRegistry)" $ do
       let reg = mkReg [mkEntry "alpha" (Just "1.0.0")] []
           report = computeRegistrySync reg []
-          warnings = mapMaybe formatDriftWarning report.syncDiffs
+          warnings = mapMaybe formatDriftWarning (report ^. #diffs)
       warnings `shouldBe` []
 
     it "mentions prompt.dhall in stale prompt warnings" $ do
-      let reg = (mkReg [] []) {prompts = [mkEntry "review" (Just "0.1.0")]}
+      let reg = ((mkReg [] []) & #prompts .~ [mkEntry "review" (Just "0.1.0")])
           lookups = [(PromptEntry, ModuleName "review", Just "0.2.0")]
           report = computeRegistrySync reg lookups
-          warnings = mapMaybe formatDriftWarning report.syncDiffs
+          warnings = mapMaybe formatDriftWarning (report ^. #diffs)
       warnings
         `shouldBe` [ "prompt 'review' registry version 0.1.0 differs from prompt.dhall version 0.2.0 — run `seihou registry sync-versions`"
                    ]
diff --git a/test/Seihou/Core/ScaffoldSpec.hs b/test/Seihou/Core/ScaffoldSpec.hs
--- a/test/Seihou/Core/ScaffoldSpec.hs
+++ b/test/Seihou/Core/ScaffoldSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.ScaffoldSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.AgentPrompt (validateAgentPrompt)
 import Seihou.Core.Blueprint (validateBlueprint)
@@ -52,7 +54,7 @@
         result <- evalModuleFromFile dhallFile
         case result of
           Left err -> expectationFailure $ "Failed to load generated module: " ++ show err
-          Right m -> m.name `shouldBe` "test-mod"
+          Right m -> (m ^. #name) `shouldBe` "test-mod"
 
     it "generates a module that passes validateModule" $ do
       schemaPath <- resolveSchemaPath
@@ -85,13 +87,13 @@
         case result of
           Left err -> expectationFailure $ "Failed to load: " ++ show err
           Right m -> do
-            length (m.vars) `shouldBe` 1
-            map (.name) m.vars `shouldBe` ["project.name"]
-            length (m.steps) `shouldBe` 1
-            length (m.prompts) `shouldBe` 1
-            length (m.commands) `shouldBe` 0
-            length (m.exports) `shouldBe` 0
-            length (m.dependencies) `shouldBe` 0
+            length (m ^. #vars) `shouldBe` 1
+            map (^. #name) (m ^. #vars) `shouldBe` ["project.name"]
+            length (m ^. #steps) `shouldBe` 1
+            length (m ^. #prompts) `shouldBe` 1
+            length (m ^. #commands) `shouldBe` 0
+            length (m ^. #exports) `shouldBe` 0
+            length (m ^. #dependencies) `shouldBe` 0
 
   describe "readmeTemplate" $ do
     it "contains the project.name placeholder" $ do
@@ -122,7 +124,7 @@
         result <- evalBlueprintFromFile dhallFile
         case result of
           Left err -> expectationFailure $ "Failed to load generated blueprint: " ++ show err
-          Right b -> b.name `shouldBe` "test-bp"
+          Right b -> (b ^. #name) `shouldBe` "test-bp"
 
     it "produces a blueprint that passes validateBlueprint" $ do
       schemaPath <- resolveSchemaPath
@@ -155,13 +157,13 @@
         case result of
           Left err -> expectationFailure $ "Failed to load: " ++ show err
           Right b -> do
-            length (b.vars) `shouldBe` 1
-            map (.name) b.vars `shouldBe` ["project.name"]
-            length (b.prompts) `shouldBe` 1
-            length (b.baseModules) `shouldBe` 0
-            length (b.files) `shouldBe` 0
-            length (b.tags) `shouldBe` 0
-            length (b.migrations) `shouldBe` 0
+            length (b ^. #vars) `shouldBe` 1
+            map (^. #name) (b ^. #vars) `shouldBe` ["project.name"]
+            length (b ^. #prompts) `shouldBe` 1
+            length (b ^. #baseModules) `shouldBe` 0
+            length (b ^. #files) `shouldBe` 0
+            length (b ^. #tags) `shouldBe` 0
+            length (b ^. #migrations) `shouldBe` 0
 
   describe "examplePromptMarkdown" $ do
     it "contains the {{project.name}} placeholder so authors see substitution" $ do
@@ -194,7 +196,7 @@
         result <- evalAgentPromptFromFile dhallFile
         case result of
           Left err -> expectationFailure $ "Failed to load generated prompt: " ++ show err
-          Right p -> p.name `shouldBe` "review-changes"
+          Right p -> (p ^. #name) `shouldBe` "review-changes"
 
     it "produces a prompt that passes validateAgentPrompt" $ do
       schemaPath <- resolveSchemaPath
@@ -227,12 +229,12 @@
         case result of
           Left err -> expectationFailure $ "Failed to load: " ++ show err
           Right p -> do
-            length (p.vars) `shouldBe` 1
-            map (.name) p.vars `shouldBe` ["project.name"]
-            length (p.prompts) `shouldBe` 0
-            length (p.commandVars) `shouldBe` 0
-            length (p.files) `shouldBe` 0
-            length (p.tags) `shouldBe` 0
+            length (p ^. #vars) `shouldBe` 1
+            map (^. #name) (p ^. #vars) `shouldBe` ["project.name"]
+            length (p ^. #prompts) `shouldBe` 0
+            length (p ^. #commandVars) `shouldBe` 0
+            length (p ^. #files) `shouldBe` 0
+            length (p ^. #tags) `shouldBe` 0
 
   describe "exampleAgentPromptMarkdown" $ do
     it "contains the {{project.name}} placeholder so debug rendering shows substitution" $ do
diff --git a/test/Seihou/Core/StatusSpec.hs b/test/Seihou/Core/StatusSpec.hs
--- a/test/Seihou/Core/StatusSpec.hs
+++ b/test/Seihou/Core/StatusSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.StatusSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
 import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
@@ -43,37 +45,37 @@
     it "classifies a file matching its manifest hash as TfsUnchanged" $ do
       let content = "# Hello World"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files = Map.singleton "README.md" (mkRecord content)
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.singleton "README.md" (mkRecord content)
+            )
           fs = PureFS (Map.singleton "README.md" content) mempty
           result = runStatus fs manifest
       length result `shouldBe` 1
-      (head result).path `shouldBe` "README.md"
-      (head result).moduleName `shouldBe` modName
-      (head result).status `shouldBe` TfsUnchanged
+      ((head result) ^. #path) `shouldBe` "README.md"
+      ((head result) ^. #moduleName) `shouldBe` modName
+      ((head result) ^. #status) `shouldBe` TfsUnchanged
 
     it "classifies a file with different disk content as TfsModified" $ do
       let originalContent = "# Hello"
           modifiedContent = "# Hello - edited"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files = Map.singleton "README.md" (mkRecord originalContent)
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.singleton "README.md" (mkRecord originalContent)
+            )
           fs = PureFS (Map.singleton "README.md" modifiedContent) mempty
           result = runStatus fs manifest
       length result `shouldBe` 1
-      (head result).status `shouldBe` TfsModified
+      ((head result) ^. #status) `shouldBe` TfsModified
 
     it "classifies a file missing from disk as TfsDeleted" $ do
       let content = "# Hello"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files = Map.singleton "README.md" (mkRecord content)
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.singleton "README.md" (mkRecord content)
+            )
           result = runStatus emptyFS manifest
       length result `shouldBe` 1
-      (head result).status `shouldBe` TfsDeleted
+      ((head result) ^. #status) `shouldBe` TfsDeleted
 
     it "handles mixed statuses across multiple files" $ do
       let unchangedContent = "unchanged"
@@ -81,14 +83,9 @@
           modifiedCurrent = "edited"
           deletedContent = "deleted"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files =
-                  Map.fromList
-                    [ ("a.txt", mkRecord unchangedContent),
-                      ("b.txt", mkRecord modifiedOriginal),
-                      ("c.txt", mkRecord deletedContent)
-                    ]
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.fromList [("a.txt", mkRecord unchangedContent), ("b.txt", mkRecord modifiedOriginal), ("c.txt", mkRecord deletedContent)]
+            )
           fs =
             PureFS
               ( Map.fromList
@@ -100,12 +97,12 @@
           result = runStatus fs manifest
       length result `shouldBe` 3
       -- Results are sorted by path
-      (result !! 0).path `shouldBe` "a.txt"
-      (result !! 0).status `shouldBe` TfsUnchanged
-      (result !! 1).path `shouldBe` "b.txt"
-      (result !! 1).status `shouldBe` TfsModified
-      (result !! 2).path `shouldBe` "c.txt"
-      (result !! 2).status `shouldBe` TfsDeleted
+      ((result !! 0) ^. #path) `shouldBe` "a.txt"
+      ((result !! 0) ^. #status) `shouldBe` TfsUnchanged
+      ((result !! 1) ^. #path) `shouldBe` "b.txt"
+      ((result !! 1) ^. #status) `shouldBe` TfsModified
+      ((result !! 2) ^. #path) `shouldBe` "c.txt"
+      ((result !! 2) ^. #status) `shouldBe` TfsDeleted
 
     it "returns empty list for empty manifest" $ do
       let manifest = emptyManifest fixedTime
diff --git a/test/Seihou/Core/TypesSpec.hs b/test/Seihou/Core/TypesSpec.hs
--- a/test/Seihou/Core/TypesSpec.hs
+++ b/test/Seihou/Core/TypesSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.TypesSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Seihou.Core.Types
 import Test.Hspec
 import Test.Tasty
@@ -13,7 +15,7 @@
   describe "ModuleName" $ do
     it "supports OverloadedStrings" $ do
       let name = "my-module" :: ModuleName
-      name.unModuleName `shouldBe` "my-module"
+      (name ^. #unModuleName) `shouldBe` "my-module"
 
     it "supports Eq" $ do
       ("a" :: ModuleName) `shouldBe` ("a" :: ModuleName)
@@ -25,7 +27,7 @@
   describe "VarName" $ do
     it "supports OverloadedStrings" $ do
       let name = "project.name" :: VarName
-      name.unVarName `shouldBe` "project.name"
+      (name ^. #unVarName) `shouldBe` "project.name"
 
   describe "VarType" $ do
     it "has five distinct constructors" $ do
@@ -58,7 +60,7 @@
                 required = True,
                 validation = Just (ValPattern "[a-z][a-z0-9-]*")
               }
-      decl.required `shouldBe` True
+      (decl ^. #required) `shouldBe` True
 
   describe "Strategy" $ do
     it "has four distinct constructors" $ do
@@ -82,7 +84,7 @@
                 removal = Nothing,
                 migrations = []
               }
-      m.name `shouldBe` "haskell-base"
+      (m ^. #name) `shouldBe` "haskell-base"
 
     it "supports Eq for identical values" $ do
       let m =
@@ -120,20 +122,20 @@
 
   describe "Operation" $ do
     it "supports WriteFileOp" $ do
-      let op = WriteFileOp {dest = "README.md", content = "# Hello", strategy = Template}
-      op.dest `shouldBe` "README.md"
+      let WriteFileOp {dest} = WriteFileOp {dest = "README.md", content = "# Hello", strategy = Template}
+      dest `shouldBe` "README.md"
 
     it "supports CreateDirOp" $ do
-      let op = CreateDirOp {path = "src"}
-      op.path `shouldBe` "src"
+      let CreateDirOp {path} = CreateDirOp {path = "src"}
+      path `shouldBe` "src"
 
     it "supports CopyFileOp" $ do
-      let op = CopyFileOp {src = "a.txt", dest = "b.txt"}
-      op.src `shouldBe` "a.txt"
+      let CopyFileOp {src} = CopyFileOp {src = "a.txt", dest = "b.txt"}
+      src `shouldBe` "a.txt"
 
     it "supports RunCommandOp" $ do
-      let op = RunCommandOp {command = "git init", workDir = Nothing, moduleName = "test", occurrence = 0}
-      op.command `shouldBe` "git init"
+      let RunCommandOp {command} = RunCommandOp {command = "git init", workDir = Nothing, moduleName = "test", occurrence = 0}
+      command `shouldBe` "git init"
 
   describe "Expr" $ do
     it "supports ExprIsSet" $ do
@@ -150,4 +152,4 @@
     it "has a version field" $ do
       -- Verify the Manifest type is a record with expected fields
       let hash = SHA256 "abc"
-      hash.unSHA256 `shouldBe` "abc"
+      (hash ^. #unSHA256) `shouldBe` "abc"
diff --git a/test/Seihou/Core/VariableSpec.hs b/test/Seihou/Core/VariableSpec.hs
--- a/test/Seihou/Core/VariableSpec.hs
+++ b/test/Seihou/Core/VariableSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Core.VariableSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Core.Expr (evalExpr, parseExpr)
@@ -196,8 +198,8 @@
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
           let rv = resolved Map.! "project.name"
-          rv.value `shouldBe` VText "my-app"
-          rv.source `shouldBe` FromCLI
+          (rv ^. #value) `shouldBe` VText "my-app"
+          (rv ^. #source) `shouldBe` FromCLI
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves from environment variables" $ do
@@ -207,8 +209,8 @@
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
           let rv = resolved Map.! "project.name"
-          rv.value `shouldBe` VText "env-app"
-          rv.source `shouldBe` FromEnv "SEIHOU_VAR_PROJECT_NAME"
+          (rv ^. #value) `shouldBe` VText "env-app"
+          (rv ^. #source) `shouldBe` FromEnv "SEIHOU_VAR_PROJECT_NAME"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves from module defaults" $ do
@@ -218,8 +220,8 @@
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
           let rv = resolved Map.! "project.version"
-          rv.value `shouldBe` VText "0.1.0.0"
-          rv.source `shouldBe` FromDefault
+          (rv ^. #value) `shouldBe` VText "0.1.0.0"
+          (rv ^. #source) `shouldBe` FromDefault
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "rejects missing required variable" $ do
@@ -236,8 +238,8 @@
           env = Map.fromList [("SEIHOU_VAR_PROJECT_NAME", "env-app")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
-          (.source) (resolved Map.! "project.name") `shouldBe` FromCLI
+          (^. #value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
+          (^. #source) (resolved Map.! "project.name") `shouldBe` FromCLI
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "environment variable beats module default" $ do
@@ -246,8 +248,8 @@
           env = Map.fromList [("SEIHOU_VAR_PROJECT_NAME", "env-app")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "project.name") `shouldBe` VText "env-app"
-          (.source) (resolved Map.! "project.name") `shouldBe` FromEnv "SEIHOU_VAR_PROJECT_NAME"
+          (^. #value) (resolved Map.! "project.name") `shouldBe` VText "env-app"
+          (^. #source) (resolved Map.! "project.name") `shouldBe` FromEnv "SEIHOU_VAR_PROJECT_NAME"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "CLI override beats module default" $ do
@@ -256,7 +258,7 @@
           env = Map.empty
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
+          (^. #value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves multiple variables with mixed sources" $ do
@@ -269,12 +271,12 @@
           env = Map.fromList [("SEIHOU_VAR_LICENSE", "BSD3")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "project.name") `shouldBe` VText "my-app"
-          (.source) (resolved Map.! "project.name") `shouldBe` FromCLI
-          (.value) (resolved Map.! "project.version") `shouldBe` VText "0.1.0.0"
-          (.source) (resolved Map.! "project.version") `shouldBe` FromDefault
-          (.value) (resolved Map.! "license") `shouldBe` VText "BSD3"
-          (.source) (resolved Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
+          (^. #value) (resolved Map.! "project.name") `shouldBe` VText "my-app"
+          (^. #source) (resolved Map.! "project.name") `shouldBe` FromCLI
+          (^. #value) (resolved Map.! "project.version") `shouldBe` VText "0.1.0.0"
+          (^. #source) (resolved Map.! "project.version") `shouldBe` FromDefault
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "BSD3"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "coerces CLI bool override" $ do
@@ -283,7 +285,7 @@
           env = Map.empty
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "enable.tests") `shouldBe` VBool True
+          (^. #value) (resolved Map.! "enable.tests") `shouldBe` VBool True
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "coerces env int override" $ do
@@ -292,7 +294,7 @@
           env = Map.fromList [("SEIHOU_VAR_PORT", "8080")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "port") `shouldBe` VInt 8080
+          (^. #value) (resolved Map.! "port") `shouldBe` VInt 8080
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "returns coercion error for bad int" $ do
@@ -320,7 +322,7 @@
           env = Map.empty
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "license") `shouldBe` VText "MIT"
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "MIT"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "coerces a raw-text bool default to VBool when falling through to default" $ do
@@ -331,8 +333,8 @@
           env = Map.empty
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "feature.on") `shouldBe` VBool True
-          (.source) (resolved Map.! "feature.on") `shouldBe` FromDefault
+          (^. #value) (resolved Map.! "feature.on") `shouldBe` VBool True
+          (^. #source) (resolved Map.! "feature.on") `shouldBe` FromDefault
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "coerces a raw-text int default to VInt when falling through to default" $ do
@@ -341,7 +343,7 @@
           env = Map.empty
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "retries") `shouldBe` VInt 3
+          (^. #value) (resolved Map.! "retries") `shouldBe` VInt 3
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "errors when a bool default cannot be coerced" $ do
@@ -365,7 +367,7 @@
           local = Map.fromList [("feature.on", manifestStoredText)]
       case resolveVariables decls cli env "" "" local Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "feature.on") `shouldBe` VBool True
+          (^. #value) (resolved Map.! "feature.on") `shouldBe` VBool True
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "Eq <var> true evaluates True for a defaulted bool" $ do
@@ -375,7 +377,7 @@
           env = Map.empty
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          let varMap = Map.map (.value) resolved
+          let varMap = Map.map (^. #value) resolved
           case parseExpr "Eq feature.on true" of
             Right expr -> evalExpr varMap expr `shouldBe` True
             Left err -> expectationFailure ("Expected parse, got: " <> show err)
@@ -505,8 +507,8 @@
           local = Map.fromList [("license", "MIT")]
       case resolveVariables decls cli env "" "" local Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "MIT"
-          (.source) (resolved Map.! "license") `shouldBe` FromLocalConfig
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "MIT"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromLocalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves from namespace config" $ do
@@ -516,8 +518,8 @@
           nsCfg = Map.fromList [("haskell.ghc", "9.12.2")]
       case resolveVariables decls cli env "haskell" "" Map.empty nsCfg Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "haskell.ghc") `shouldBe` VText "9.12.2"
-          (.source) (resolved Map.! "haskell.ghc") `shouldBe` FromNamespaceConfig "haskell"
+          (^. #value) (resolved Map.! "haskell.ghc") `shouldBe` VText "9.12.2"
+          (^. #source) (resolved Map.! "haskell.ghc") `shouldBe` FromNamespaceConfig "haskell"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves from global config" $ do
@@ -527,8 +529,8 @@
           global = Map.fromList [("license", "MIT")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "MIT"
-          (.source) (resolved Map.! "license") `shouldBe` FromGlobalConfig
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "MIT"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromGlobalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "local config overrides global config" $ do
@@ -539,8 +541,8 @@
           global = Map.fromList [("license", "MIT")]
       case resolveVariables decls cli env "" "" local Map.empty Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "BSD3"
-          (.source) (resolved Map.! "license") `shouldBe` FromLocalConfig
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "BSD3"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromLocalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "namespace config overrides global config" $ do
@@ -551,8 +553,8 @@
           global = Map.fromList [("license", "MIT")]
       case resolveVariables decls cli env "haskell" "" Map.empty nsCfg Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "Apache"
-          (.source) (resolved Map.! "license") `shouldBe` FromNamespaceConfig "haskell"
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "Apache"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromNamespaceConfig "haskell"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "context config resolves when namespace and local don't have variable" $ do
@@ -562,8 +564,8 @@
           ctxCfg = Map.fromList [("user.email", "me@work.com")]
       case resolveVariables decls cli env "" "work" Map.empty Map.empty ctxCfg Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "user.email") `shouldBe` VText "me@work.com"
-          (.source) (resolved Map.! "user.email") `shouldBe` FromContextConfig "work"
+          (^. #value) (resolved Map.! "user.email") `shouldBe` VText "me@work.com"
+          (^. #source) (resolved Map.! "user.email") `shouldBe` FromContextConfig "work"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "context config is lower priority than namespace config" $ do
@@ -574,8 +576,8 @@
           ctxCfg = Map.fromList [("user.email", "ctx@example.com")]
       case resolveVariables decls cli env "haskell" "work" Map.empty nsCfg ctxCfg Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "user.email") `shouldBe` VText "ns@example.com"
-          (.source) (resolved Map.! "user.email") `shouldBe` FromNamespaceConfig "haskell"
+          (^. #value) (resolved Map.! "user.email") `shouldBe` VText "ns@example.com"
+          (^. #source) (resolved Map.! "user.email") `shouldBe` FromNamespaceConfig "haskell"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "context config is higher priority than global config" $ do
@@ -586,8 +588,8 @@
           global = Map.fromList [("user.email", "global@example.com")]
       case resolveVariables decls cli env "" "work" Map.empty Map.empty ctxCfg global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "user.email") `shouldBe` VText "ctx@example.com"
-          (.source) (resolved Map.! "user.email") `shouldBe` FromContextConfig "work"
+          (^. #value) (resolved Map.! "user.email") `shouldBe` VText "ctx@example.com"
+          (^. #source) (resolved Map.! "user.email") `shouldBe` FromContextConfig "work"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "local config overrides namespace config" $ do
@@ -598,8 +600,8 @@
           nsCfg = Map.fromList [("license", "Apache")]
       case resolveVariables decls cli env "haskell" "" local nsCfg Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "GPL"
-          (.source) (resolved Map.! "license") `shouldBe` FromLocalConfig
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "GPL"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromLocalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "env overrides local config" $ do
@@ -609,8 +611,8 @@
           local = Map.fromList [("license", "local-license")]
       case resolveVariables decls cli env "" "" local Map.empty Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "env-license"
-          (.source) (resolved Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "env-license"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "CLI overrides all config layers" $ do
@@ -622,8 +624,8 @@
           global = Map.fromList [("license", "global-license")]
       case resolveVariables decls cli env "haskell" "" local nsCfg Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "cli-license"
-          (.source) (resolved Map.! "license") `shouldBe` FromCLI
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "cli-license"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromCLI
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "global config overrides module default" $ do
@@ -633,8 +635,8 @@
           global = Map.fromList [("license", "global-license")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "license") `shouldBe` VText "global-license"
-          (.source) (resolved Map.! "license") `shouldBe` FromGlobalConfig
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "global-license"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromGlobalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves mixed sources across multiple variables" $ do
@@ -649,12 +651,12 @@
           nsCfg = Map.fromList [("haskell.ghc", "9.12.2")]
       case resolveVariables decls cli env "haskell" "" local nsCfg Map.empty Map.empty Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
-          (.source) (resolved Map.! "project.name") `shouldBe` FromCLI
-          (.value) (resolved Map.! "license") `shouldBe` VText "BSD3"
-          (.source) (resolved Map.! "license") `shouldBe` FromLocalConfig
-          (.value) (resolved Map.! "haskell.ghc") `shouldBe` VText "9.12.2"
-          (.source) (resolved Map.! "haskell.ghc") `shouldBe` FromNamespaceConfig "haskell"
+          (^. #value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
+          (^. #source) (resolved Map.! "project.name") `shouldBe` FromCLI
+          (^. #value) (resolved Map.! "license") `shouldBe` VText "BSD3"
+          (^. #source) (resolved Map.! "license") `shouldBe` FromLocalConfig
+          (^. #value) (resolved Map.! "haskell.ghc") `shouldBe` VText "9.12.2"
+          (^. #source) (resolved Map.! "haskell.ghc") `shouldBe` FromNamespaceConfig "haskell"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "omits non-required variable with no value from any source" $ do
@@ -680,8 +682,8 @@
           global = Map.fromList [("optional.var", "from-global")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "optional.var") `shouldBe` VText "from-global"
-          (.source) (resolved Map.! "optional.var") `shouldBe` FromGlobalConfig
+          (^. #value) (resolved Map.! "optional.var") `shouldBe` VText "from-global"
+          (^. #source) (resolved Map.! "optional.var") `shouldBe` FromGlobalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves mix of required, optional-with-value, and optional-without-value" $ do
@@ -695,9 +697,9 @@
           global = Map.fromList [("optional.present", "found")]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty global Map.empty of
         Right resolved -> do
-          (.value) (resolved Map.! "project.name") `shouldBe` VText "my-app"
+          (^. #value) (resolved Map.! "project.name") `shouldBe` VText "my-app"
           Map.member "optional.missing" resolved `shouldBe` False
-          (.value) (resolved Map.! "optional.present") `shouldBe` VText "found"
+          (^. #value) (resolved Map.! "optional.present") `shouldBe` VText "found"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "coerces config values through type system" $ do
@@ -707,7 +709,7 @@
           local = Map.fromList [("enable.tests", "true")]
       case resolveVariables decls cli env "" "" local Map.empty Map.empty Map.empty Map.empty of
         Right resolved ->
-          (.value) (resolved Map.! "enable.tests") `shouldBe` VBool True
+          (^. #value) (resolved Map.! "enable.tests") `shouldBe` VBool True
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "resolves from parent-supplied vars" $ do
@@ -717,8 +719,8 @@
           parentVars = Map.fromList [("skill.name", ("exec-plan", "parent-mod"))]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty parentVars of
         Right resolved -> do
-          (.value) (resolved Map.! "skill.name") `shouldBe` VText "exec-plan"
-          (.source) (resolved Map.! "skill.name") `shouldBe` FromParent "parent-mod"
+          (^. #value) (resolved Map.! "skill.name") `shouldBe` VText "exec-plan"
+          (^. #source) (resolved Map.! "skill.name") `shouldBe` FromParent "parent-mod"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "parent-supplied var overrides module default" $ do
@@ -728,8 +730,8 @@
           parentVars = Map.fromList [("skill.name", ("parent-val", "parent-mod"))]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty Map.empty parentVars of
         Right resolved -> do
-          (.value) (resolved Map.! "skill.name") `shouldBe` VText "parent-val"
-          (.source) (resolved Map.! "skill.name") `shouldBe` FromParent "parent-mod"
+          (^. #value) (resolved Map.! "skill.name") `shouldBe` VText "parent-val"
+          (^. #source) (resolved Map.! "skill.name") `shouldBe` FromParent "parent-mod"
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "global config overrides parent-supplied var" $ do
@@ -740,8 +742,8 @@
           parentVars = Map.fromList [("skill.name", ("parent-val", "parent-mod"))]
       case resolveVariables decls cli env "" "" Map.empty Map.empty Map.empty global parentVars of
         Right resolved -> do
-          (.value) (resolved Map.! "skill.name") `shouldBe` VText "global-val"
-          (.source) (resolved Map.! "skill.name") `shouldBe` FromGlobalConfig
+          (^. #value) (resolved Map.! "skill.name") `shouldBe` VText "global-val"
+          (^. #source) (resolved Map.! "skill.name") `shouldBe` FromGlobalConfig
         Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
   describe "diagnoseResolution" $ do
diff --git a/test/Seihou/Dhall/EvalSpec.hs b/test/Seihou/Dhall/EvalSpec.hs
--- a/test/Seihou/Dhall/EvalSpec.hs
+++ b/test/Seihou/Dhall/EvalSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Dhall.EvalSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Core.Types
@@ -62,61 +64,61 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right m -> do
-          m.name `shouldBe` ModuleName "haskell-base"
-          m.description `shouldBe` Just "A Haskell project template"
-          length (m.vars) `shouldBe` 3
-          length (m.prompts) `shouldBe` 1
-          length (m.steps) `shouldBe` 5
-          length (m.exports) `shouldBe` 1
-          m.dependencies `shouldBe` []
+          (m ^. #name) `shouldBe` ModuleName "haskell-base"
+          (m ^. #description) `shouldBe` Just "A Haskell project template"
+          length (m ^. #vars) `shouldBe` 3
+          length (m ^. #prompts) `shouldBe` 1
+          length (m ^. #steps) `shouldBe` 5
+          length (m ^. #exports) `shouldBe` 1
+          (m ^. #dependencies) `shouldBe` []
 
     it "decodes variable declarations correctly" $ do
       result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right m -> do
-          let (projectName : projectVersion : _) = m.vars
-          projectName.name `shouldBe` VarName "project.name"
-          projectName.type_ `shouldBe` VTText
-          projectName.default_ `shouldBe` Nothing
-          projectName.required `shouldBe` True
-          projectName.validation `shouldBe` Just (ValPattern "[a-z][a-z0-9-]*")
+          let (projectName : projectVersion : _) = (m ^. #vars)
+          (projectName ^. #name) `shouldBe` VarName "project.name"
+          (projectName ^. #type_) `shouldBe` VTText
+          (projectName ^. #default_) `shouldBe` Nothing
+          (projectName ^. #required) `shouldBe` True
+          (projectName ^. #validation) `shouldBe` Just (ValPattern "[a-z][a-z0-9-]*")
 
-          projectVersion.name `shouldBe` VarName "project.version"
-          projectVersion.default_ `shouldBe` Just (VText "0.1.0.0")
-          projectVersion.required `shouldBe` False
+          (projectVersion ^. #name) `shouldBe` VarName "project.version"
+          (projectVersion ^. #default_) `shouldBe` Just (VText "0.1.0.0")
+          (projectVersion ^. #required) `shouldBe` False
 
     it "decodes steps with correct strategy" $ do
       result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right m -> do
-          let (readme : libStep : licenseStep : _) = m.steps
-          readme.strategy `shouldBe` Template
-          readme.src `shouldBe` "README.md.tpl"
-          readme.dest `shouldBe` "README.md"
-          readme.condition `shouldBe` Nothing
-          readme.patch `shouldBe` Nothing
+          let (readme : libStep : licenseStep : _) = (m ^. #steps)
+          (readme ^. #strategy) `shouldBe` Template
+          (readme ^. #src) `shouldBe` "README.md.tpl"
+          (readme ^. #dest) `shouldBe` "README.md"
+          (readme ^. #condition) `shouldBe` Nothing
+          (readme ^. #patch) `shouldBe` Nothing
 
-          libStep.strategy `shouldBe` Template
-          libStep.src `shouldBe` "src/Lib.hs.tpl"
-          libStep.dest `shouldBe` "src/Lib.hs"
-          libStep.patch `shouldBe` Nothing
+          (libStep ^. #strategy) `shouldBe` Template
+          (libStep ^. #src) `shouldBe` "src/Lib.hs.tpl"
+          (libStep ^. #dest) `shouldBe` "src/Lib.hs"
+          (libStep ^. #patch) `shouldBe` Nothing
 
-          licenseStep.strategy `shouldBe` Copy
-          licenseStep.src `shouldBe` "LICENSE"
-          licenseStep.dest `shouldBe` "LICENSE"
-          licenseStep.condition `shouldBe` Just (ExprIsSet "license")
-          licenseStep.patch `shouldBe` Nothing
+          (licenseStep ^. #strategy) `shouldBe` Copy
+          (licenseStep ^. #src) `shouldBe` "LICENSE"
+          (licenseStep ^. #dest) `shouldBe` "LICENSE"
+          (licenseStep ^. #condition) `shouldBe` Just (ExprIsSet "license")
+          (licenseStep ^. #patch) `shouldBe` Nothing
 
     it "decodes exports correctly" $ do
       result <- evalModuleFromFile (fixtureDir </> "haskell-base" </> "module.dhall")
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right m -> do
-          let (export1 : _) = m.exports
-          export1.var `shouldBe` VarName "project.name"
-          export1.alias `shouldBe` Nothing
+          let (export1 : _) = (m ^. #exports)
+          (export1 ^. #var) `shouldBe` VarName "project.name"
+          (export1 ^. #alias) `shouldBe` Nothing
 
     it "returns DhallEvalError for nonexistent file" $ do
       result <- evalModuleFromFile "/nonexistent/path/module.dhall"
@@ -146,11 +148,11 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right m -> do
-          let (prompt1 : _) = m.prompts
-          prompt1.var `shouldBe` VarName "project.name"
-          prompt1.text `shouldBe` "What is the project name?"
-          prompt1.condition `shouldBe` Nothing
-          prompt1.choices `shouldBe` Nothing
+          let (prompt1 : _) = (m ^. #prompts)
+          (prompt1 ^. #var) `shouldBe` VarName "project.name"
+          (prompt1 ^. #text) `shouldBe` "What is the project name?"
+          (prompt1 ^. #condition) `shouldBe` Nothing
+          (prompt1 ^. #choices) `shouldBe` Nothing
 
     it "decodes step with patch = Some \"append-file\"" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -192,10 +194,10 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            let (s1 : s2 : s3 : _) = m.steps
-            s1.patch `shouldBe` Just AppendFile
-            s2.patch `shouldBe` Just PrependFile
-            s3.patch `shouldBe` Just AppendSection
+            let (s1 : s2 : s3 : _) = (m ^. #steps)
+            (s1 ^. #patch) `shouldBe` Just AppendFile
+            (s2 ^. #patch) `shouldBe` Just PrependFile
+            (s3 ^. #patch) `shouldBe` Just AppendSection
 
     it "returns Left for unknown patch operation (not a crash)" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -235,9 +237,9 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            let v = head m.vars
-            v.type_ `shouldBe` VTBool
-            v.default_ `shouldBe` Just (VBool True)
+            let v = head (m ^. #vars)
+            (v ^. #type_) `shouldBe` VTBool
+            (v ^. #default_) `shouldBe` Just (VBool True)
 
     it "coerces an int default to VInt at decode time" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -246,9 +248,9 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            let v = head m.vars
-            v.type_ `shouldBe` VTInt
-            v.default_ `shouldBe` Just (VInt 3)
+            let v = head (m ^. #vars)
+            (v ^. #type_) `shouldBe` VTInt
+            (v ^. #default_) `shouldBe` Just (VInt 3)
 
     it "fails module load on a malformed bool default" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -284,10 +286,10 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            length m.dependencies `shouldBe` 1
-            let dep = head m.dependencies
-            dep.depModule `shouldBe` ModuleName "base"
-            Map.null dep.depVars `shouldBe` True
+            length (m ^. #dependencies) `shouldBe` 1
+            let dep = head (m ^. #dependencies)
+            (dep ^. #module_) `shouldBe` ModuleName "base"
+            Map.null (dep ^. #vars) `shouldBe` True
 
     it "decodes a parameterized record dependency" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -296,10 +298,10 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            length m.dependencies `shouldBe` 1
-            let dep = head m.dependencies
-            dep.depModule `shouldBe` ModuleName "base"
-            Map.lookup (VarName "x") dep.depVars `shouldBe` Just "y"
+            length (m ^. #dependencies) `shouldBe` 1
+            let dep = head (m ^. #dependencies)
+            (dep ^. #module_) `shouldBe` ModuleName "base"
+            Map.lookup (VarName "x") (dep ^. #vars) `shouldBe` Just "y"
 
     it "decodes a parameterized dependency with empty vars" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -308,10 +310,10 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            length m.dependencies `shouldBe` 1
-            let dep = head m.dependencies
-            dep.depModule `shouldBe` ModuleName "base"
-            Map.null dep.depVars `shouldBe` True
+            length (m ^. #dependencies) `shouldBe` 1
+            let dep = head (m ^. #dependencies)
+            (dep ^. #module_) `shouldBe` ModuleName "base"
+            Map.null (dep ^. #vars) `shouldBe` True
 
     it "decodes a module.dhall with parameterized dependencies" $ do
       withSystemTempDirectory "seihou-eval-test" $ \tmpDir -> do
@@ -320,10 +322,10 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right m -> do
-            length m.dependencies `shouldBe` 1
-            let dep = head m.dependencies
-            dep.depModule `shouldBe` ModuleName "child-mod"
-            Map.lookup (VarName "skill.name") dep.depVars `shouldBe` Just "exec-plan"
+            length (m ^. #dependencies) `shouldBe` 1
+            let dep = head (m ^. #dependencies)
+            (dep ^. #module_) `shouldBe` ModuleName "child-mod"
+            Map.lookup (VarName "skill.name") (dep ^. #vars) `shouldBe` Just "exec-plan"
 
   describe "evalRecipeFromFile" $ do
     it "decodes the haskell-with-nix-recipe fixture" $ do
@@ -331,30 +333,30 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right r -> do
-          r.name `shouldBe` RecipeName "haskell-with-nix"
-          r.version `shouldBe` Just "1.0.0"
-          r.description `shouldBe` Just "Haskell project with Nix integration"
-          length r.modules `shouldBe` 2
-          let (m1 : m2 : _) = r.modules
-          m1.depModule `shouldBe` ModuleName "haskell-base"
-          Map.null m1.depVars `shouldBe` True
-          m2.depModule `shouldBe` ModuleName "nix-flake"
-          Map.null m2.depVars `shouldBe` True
-          r.vars `shouldBe` []
-          r.prompts `shouldBe` []
+          (r ^. #name) `shouldBe` RecipeName "haskell-with-nix"
+          (r ^. #version) `shouldBe` Just "1.0.0"
+          (r ^. #description) `shouldBe` Just "Haskell project with Nix integration"
+          length (r ^. #modules) `shouldBe` 2
+          let (m1 : m2 : _) = (r ^. #modules)
+          (m1 ^. #module_) `shouldBe` ModuleName "haskell-base"
+          Map.null (m1 ^. #vars) `shouldBe` True
+          (m2 ^. #module_) `shouldBe` ModuleName "nix-flake"
+          Map.null (m2 ^. #vars) `shouldBe` True
+          (r ^. #vars) `shouldBe` []
+          (r ^. #prompts) `shouldBe` []
 
     it "decodes the haskell-pinned-recipe fixture with variable bindings" $ do
       result <- evalRecipeFromFile (fixtureDir </> "haskell-pinned-recipe" </> "recipe.dhall")
       case result of
         Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
         Right r -> do
-          r.name `shouldBe` RecipeName "haskell-pinned"
-          length r.modules `shouldBe` 2
-          let (m1 : m2 : _) = r.modules
-          m1.depModule `shouldBe` ModuleName "haskell-base"
-          Map.null m1.depVars `shouldBe` True
-          m2.depModule `shouldBe` ModuleName "nix-flake"
-          Map.lookup (VarName "nix.system") m2.depVars `shouldBe` Just "aarch64-darwin"
+          (r ^. #name) `shouldBe` RecipeName "haskell-pinned"
+          length (r ^. #modules) `shouldBe` 2
+          let (m1 : m2 : _) = (r ^. #modules)
+          (m1 ^. #module_) `shouldBe` ModuleName "haskell-base"
+          Map.null (m1 ^. #vars) `shouldBe` True
+          (m2 ^. #module_) `shouldBe` ModuleName "nix-flake"
+          Map.lookup (VarName "nix.system") (m2 ^. #vars) `shouldBe` Just "aarch64-darwin"
 
     it "returns DhallEvalError for nonexistent recipe file" $ do
       result <- evalRecipeFromFile "/nonexistent/path/recipe.dhall"
@@ -394,13 +396,13 @@
         case result of
           Left err -> expectationFailure ("Expected Right, got Left: " <> show err)
           Right r -> do
-            r.name `shouldBe` RecipeName "prompted-recipe"
-            length r.vars `shouldBe` 1
-            let v = head r.vars
-            v.name `shouldBe` VarName "project.name"
-            v.type_ `shouldBe` VTText
-            v.required `shouldBe` True
-            length r.prompts `shouldBe` 1
-            let p = head r.prompts
-            p.var `shouldBe` VarName "project.name"
-            p.text `shouldBe` "What is the project name?"
+            (r ^. #name) `shouldBe` RecipeName "prompted-recipe"
+            length (r ^. #vars) `shouldBe` 1
+            let v = head (r ^. #vars)
+            (v ^. #name) `shouldBe` VarName "project.name"
+            (v ^. #type_) `shouldBe` VTText
+            (v ^. #required) `shouldBe` True
+            length (r ^. #prompts) `shouldBe` 1
+            let p = head (r ^. #prompts)
+            (p ^. #var) `shouldBe` VarName "project.name"
+            (p ^. #text) `shouldBe` "What is the project name?"
diff --git a/test/Seihou/Dhall/MigrationDecoderSpec.hs b/test/Seihou/Dhall/MigrationDecoderSpec.hs
--- a/test/Seihou/Dhall/MigrationDecoderSpec.hs
+++ b/test/Seihou/Dhall/MigrationDecoderSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Dhall.MigrationDecoderSpec (tests) where
 
+import Control.Lens (to, (^.))
+import Data.Generics.Labels ()
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.IO qualified as TIO
@@ -22,19 +24,19 @@
     it "decodes a module with no migrations field as []" $
       withModuleDhall noMigrationsField $ \result ->
         case result of
-          Right m -> m.migrations `shouldBe` []
+          Right m -> (m ^. #migrations) `shouldBe` []
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
     it "decodes a module with an empty migrations list" $
       withModuleDhall emptyMigrations $ \result ->
         case result of
-          Right m -> m.migrations `shouldBe` []
+          Right m -> (m ^. #migrations) `shouldBe` []
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
     it "decodes a single MoveFile migration" $
       withModuleDhall (oneMigration moveFileOp) $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {from = "1.0.0", to = "2.0.0", ops = [op]}] ->
               op `shouldBe` MoveFile {src = "old/Path.hs", dest = "new/Path.hs"}
             other -> expectationFailure ("Unexpected migrations: " <> show other)
@@ -43,7 +45,7 @@
     it "decodes a MoveDir migration" $
       withModuleDhall (oneMigration moveDirOp) $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {ops = [op]}] ->
               op `shouldBe` MoveDir {src = "app", dest = "src"}
             other -> expectationFailure ("Unexpected migrations: " <> show other)
@@ -52,7 +54,7 @@
     it "decodes a DeleteFile migration" $
       withModuleDhall (oneMigration deleteFileOp) $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {ops = [op]}] ->
               op `shouldBe` DeleteFile {path = "Setup.hs"}
             other -> expectationFailure ("Unexpected migrations: " <> show other)
@@ -61,7 +63,7 @@
     it "decodes a DeleteDir migration" $
       withModuleDhall (oneMigration deleteDirOp) $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {ops = [op]}] ->
               op `shouldBe` DeleteDir {path = "obsolete"}
             other -> expectationFailure ("Unexpected migrations: " <> show other)
@@ -70,7 +72,7 @@
     it "decodes a RunCommand migration without workDir" $
       withModuleDhall (oneMigration runCommandNoWorkDir) $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {ops = [op]}] ->
               op `shouldBe` RunCommand {run = "echo hi", workDir = Nothing}
             other -> expectationFailure ("Unexpected migrations: " <> show other)
@@ -79,7 +81,7 @@
     it "decodes a RunCommand migration with workDir" $
       withModuleDhall (oneMigration runCommandWithWorkDir) $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {ops = [op]}] ->
               op `shouldBe` RunCommand {run = "make clean", workDir = Just "build"}
             other -> expectationFailure ("Unexpected migrations: " <> show other)
@@ -88,7 +90,7 @@
     it "decodes multiple ops in a single migration in declaration order" $
       withModuleDhall multiOpMigration $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [Migration {ops}] ->
               ops
                 `shouldBe` [ MoveDir {src = "app", dest = "src"},
@@ -101,12 +103,12 @@
     it "decodes multiple migrations in declaration order" $
       withModuleDhall twoChainedMigrations $ \result ->
         case result of
-          Right m -> case m.migrations of
+          Right m -> case m ^. #migrations of
             [m1, m2] -> do
-              m1.from `shouldBe` "1.0.0"
-              m1.to `shouldBe` "2.0.0"
-              m2.from `shouldBe` "2.0.0"
-              m2.to `shouldBe` "3.0.0"
+              (m1 ^. #from) `shouldBe` "1.0.0"
+              (m1 ^. #to) `shouldBe` "2.0.0"
+              (m2 ^. #from) `shouldBe` "2.0.0"
+              (m2 ^. #to) `shouldBe` "3.0.0"
             other -> expectationFailure ("Unexpected migrations: " <> show other)
           Left err -> expectationFailure ("Expected Right, got: " <> show err)
 
diff --git a/test/Seihou/Effect/BaselineStoreSpec.hs b/test/Seihou/Effect/BaselineStoreSpec.hs
--- a/test/Seihou/Effect/BaselineStoreSpec.hs
+++ b/test/Seihou/Effect/BaselineStoreSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Effect.BaselineStoreSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -81,10 +83,10 @@
       beforeTamper `shouldBe` Right "kept"
       afterTamper `shouldBe` Left (BaselineCorrupt kept (hashContent "tampered"))
       pruned `shouldBe` [removed]
-      Map.lookup keptPath fs.files `shouldBe` Just "kept"
-      Map.member removedPath fs.files `shouldBe` False
-      Map.lookup unrelated fs.files `shouldBe` Just "leave me"
-      Map.member staleTemp fs.files `shouldBe` False
+      Map.lookup keptPath (fs ^. #files) `shouldBe` Just "kept"
+      Map.member removedPath (fs ^. #files) `shouldBe` False
+      Map.lookup unrelated (fs ^. #files) `shouldBe` Just "leave me"
+      Map.member staleTemp (fs ^. #files) `shouldBe` False
 
     it "round-trips on a real filesystem with one deduplicated blob" $ do
       withSystemTempDirectory "seihou-baselines" $ \tmpDir -> do
diff --git a/test/Seihou/Effect/ConfigWriterSpec.hs b/test/Seihou/Effect/ConfigWriterSpec.hs
--- a/test/Seihou/Effect/ConfigWriterSpec.hs
+++ b/test/Seihou/Effect/ConfigWriterSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Effect.ConfigWriterSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Effectful
 import Seihou.Core.Types (ConfigScope (..))
@@ -25,14 +27,14 @@
       result `shouldBe` Right (Map.fromList [("project.name", "my-app")])
 
     it "overwrites an existing value" $ do
-      let initial = emptyConfigWriterState {cwLocal = Map.fromList [("key", "old")]}
+      let initial = emptyConfigWriterState & #local .~ Map.fromList [("key", "old")]
           (result, _) = run initial $ do
             writeConfigValue ScopeLocal "key" "new"
             listConfigValues ScopeLocal
       result `shouldBe` Right (Map.fromList [("key", "new")])
 
     it "preserves other keys when writing" $ do
-      let initial = emptyConfigWriterState {cwLocal = Map.fromList [("existing", "keep")]}
+      let initial = emptyConfigWriterState & #local .~ Map.fromList [("existing", "keep")]
           (result, _) = run initial $ do
             writeConfigValue ScopeLocal "new-key" "added"
             listConfigValues ScopeLocal
@@ -68,21 +70,21 @@
 
   describe "deleteConfigValue" $ do
     it "removes an existing value" $ do
-      let initial = emptyConfigWriterState {cwLocal = Map.fromList [("key", "val")]}
+      let initial = emptyConfigWriterState & #local .~ Map.fromList [("key", "val")]
           (result, _) = run initial $ do
             deleteConfigValue ScopeLocal "key"
             listConfigValues ScopeLocal
       result `shouldBe` Right Map.empty
 
     it "is a no-op for nonexistent key" $ do
-      let initial = emptyConfigWriterState {cwLocal = Map.fromList [("keep", "me")]}
+      let initial = emptyConfigWriterState & #local .~ Map.fromList [("keep", "me")]
           (result, _) = run initial $ do
             deleteConfigValue ScopeLocal "nonexistent"
             listConfigValues ScopeLocal
       result `shouldBe` Right (Map.fromList [("keep", "me")])
 
     it "deletes from global scope" $ do
-      let initial = emptyConfigWriterState {cwGlobal = Map.fromList [("license", "MIT")]}
+      let initial = emptyConfigWriterState & #global .~ Map.fromList [("license", "MIT")]
           (result, _) = run initial $ do
             deleteConfigValue ScopeGlobal "license"
             listConfigValues ScopeGlobal
@@ -94,6 +96,6 @@
             writeConfigValue ScopeLocal "local.key" "l"
             writeConfigValue ScopeGlobal "global.key" "g"
             writeConfigValue (ScopeNamespace "ns") "ns.key" "n"
-      finalState.cwLocal `shouldBe` Map.fromList [("local.key", "l")]
-      finalState.cwGlobal `shouldBe` Map.fromList [("global.key", "g")]
-      Map.lookup "ns" (finalState.cwNamespaces) `shouldBe` Just (Map.fromList [("ns.key", "n")])
+      (finalState ^. #local) `shouldBe` Map.fromList [("local.key", "l")]
+      (finalState ^. #global) `shouldBe` Map.fromList [("global.key", "g")]
+      Map.lookup "ns" (finalState ^. #namespaces) `shouldBe` Just (Map.fromList [("ns.key", "n")])
diff --git a/test/Seihou/Effect/FilesystemSpec.hs b/test/Seihou/Effect/FilesystemSpec.hs
--- a/test/Seihou/Effect/FilesystemSpec.hs
+++ b/test/Seihou/Effect/FilesystemSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Effect.FilesystemSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Effectful
@@ -76,7 +78,7 @@
       let (_, fs) = runPure emptyFS $ do
             writeFileText "a.txt" "aaa"
             writeFileText "b.txt" "bbb"
-      Map.size fs.files `shouldBe` 2
+      Map.size (fs ^. #files) `shouldBe` 2
 
     it "getCurrentDirectory returns /pure-fs" $ do
       let (cwd, _) = runPure emptyFS getCurrentDirectory
diff --git a/test/Seihou/Effect/LoggerSpec.hs b/test/Seihou/Effect/LoggerSpec.hs
--- a/test/Seihou/Effect/LoggerSpec.hs
+++ b/test/Seihou/Effect/LoggerSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Effect.LoggerSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Effectful
 import Seihou.Core.Types (LogLevel (..))
 import Seihou.Effect.Logger (logDebug, logError, logInfo, logWarn)
@@ -21,10 +23,10 @@
             logInfo "i1"
             logWarn "w1"
             logError "e1"
-      st.logDebugMsgs `shouldBe` ["d1"]
-      st.logInfoMsgs `shouldBe` ["i1"]
-      st.logWarnMsgs `shouldBe` ["w1"]
-      st.logErrorMsgs `shouldBe` ["e1"]
+      (st ^. #debugMsgs) `shouldBe` ["d1"]
+      (st ^. #infoMsgs) `shouldBe` ["i1"]
+      (st ^. #warnMsgs) `shouldBe` ["w1"]
+      (st ^. #errorMsgs) `shouldBe` ["e1"]
 
     it "preserves message order within each field" $ do
       let ((), st) = runPureEff $ runLoggerPure $ do
@@ -33,22 +35,22 @@
             logInfo "third"
             logDebug "a"
             logDebug "b"
-      st.logInfoMsgs `shouldBe` ["first", "second", "third"]
-      st.logDebugMsgs `shouldBe` ["a", "b"]
+      (st ^. #infoMsgs) `shouldBe` ["first", "second", "third"]
+      (st ^. #debugMsgs) `shouldBe` ["a", "b"]
 
     it "produces empty state when no messages are logged" $ do
       let ((), st) = runPureEff $ runLoggerPure $ pure ()
-      st.logDebugMsgs `shouldBe` []
-      st.logInfoMsgs `shouldBe` []
-      st.logWarnMsgs `shouldBe` []
-      st.logErrorMsgs `shouldBe` []
+      (st ^. #debugMsgs) `shouldBe` []
+      (st ^. #infoMsgs) `shouldBe` []
+      (st ^. #warnMsgs) `shouldBe` []
+      (st ^. #errorMsgs) `shouldBe` []
 
     it "returns the computation result alongside state" $ do
       let (result, st) = runPureEff $ runLoggerPure $ do
             logInfo "hello"
             pure (42 :: Int)
       result `shouldBe` 42
-      st.logInfoMsgs `shouldBe` ["hello"]
+      (st ^. #infoMsgs) `shouldBe` ["hello"]
 
   describe "shouldLog" $ do
     it "LogVerbose configured shows all levels" $ do
diff --git a/test/Seihou/Effect/ManifestStoreSpec.hs b/test/Seihou/Effect/ManifestStoreSpec.hs
--- a/test/Seihou/Effect/ManifestStoreSpec.hs
+++ b/test/Seihou/Effect/ManifestStoreSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Effect.ManifestStoreSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -29,17 +31,9 @@
 sampleManifest :: Manifest
 sampleManifest =
   (emptyManifest fixedTime)
-    { modules =
-        [ AppliedModule (ModuleName "haskell-base") emptyParentVars "/path/to/mod" Nothing fixedTime Nothing
-        ],
-      vars = Map.fromList [(VarName "project.name", "my-app")],
-      files =
-        Map.fromList
-          [ ( "README.md",
-              FileRecord (SHA256 "abc123") (ModuleName "haskell-base") Template fixedTime Nothing mempty
-            )
-          ]
-    }
+    & #modules .~ [AppliedModule (ModuleName "haskell-base") emptyParentVars (LocalOrigin "haskell-base") Nothing fixedTime Nothing]
+    & #vars .~ Map.fromList [(VarName "project.name", "my-app")]
+    & #files .~ Map.fromList [("README.md", FileRecord (SHA256 "abc123") (ModuleName "haskell-base") Template fixedTime Nothing mempty)]
 
 spec :: Spec
 spec = do
@@ -94,7 +88,7 @@
                 runManifestStore manifestPath (writeManifest sampleManifest)
                 c <- readFileText manifestPath
                 pure ((), c)
-      T.isInfixOf "\"version\":5" content `shouldBe` True
+      T.isInfixOf "\"version\":6" content `shouldBe` True
       T.isInfixOf "haskell-base" content `shouldBe` True
       T.isInfixOf "my-app" content `shouldBe` True
 
@@ -104,9 +98,9 @@
             runPureEff $
               runFilesystemPure emptyFS $
                 runManifestStore manifestPath (writeManifest sampleManifest)
-      Map.member manifestPath finalFS.files `shouldBe` True
-      Map.member (manifestPath <> ".tmp") finalFS.files `shouldBe` False
-      Set.member ".seihou" finalFS.dirs `shouldBe` True
+      Map.member manifestPath (finalFS ^. #files) `shouldBe` True
+      Map.member (manifestPath <> ".tmp") (finalFS ^. #files) `shouldBe` False
+      Set.member ".seihou" (finalFS ^. #dirs) `shouldBe` True
 
     it "returns Left for corrupt JSON" $ do
       let manifestPath = ".seihou/manifest.json"
diff --git a/test/Seihou/Engine/BaselineSpec.hs b/test/Seihou/Engine/BaselineSpec.hs
--- a/test/Seihou/Engine/BaselineSpec.hs
+++ b/test/Seihou/Engine/BaselineSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.BaselineSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
@@ -48,9 +50,9 @@
         Right records -> do
           let enriched = records Map.! path
               expectedRef = baselineRefForContent content
-          enriched.hash `shouldBe` hashContent content
-          enriched.baseline `shouldBe` Just expectedRef
-          enriched.applicationIds `shouldBe` Set.singleton applicationId
+          (enriched ^. #hash) `shouldBe` hashContent content
+          (enriched ^. #baseline) `shouldBe` Just expectedRef
+          (enriched ^. #applicationIds) `shouldBe` Set.singleton applicationId
           Map.lookup expectedRef stored `shouldBe` Just content
 
     it "returns an error and publishes no reference when a generated file is missing" $ do
@@ -74,7 +76,7 @@
                   captured <- recordGeneratedBaselines "" (Map.singleton "copy.txt" record)
                   case captured of
                     Left err -> pure (Left err)
-                    Right records -> case (records Map.! "copy.txt").baseline of
+                    Right records -> case (records Map.! "copy.txt") ^. #baseline of
                       Nothing -> pure (Left (BaselineStoreFailure "missing reference"))
                       Just ref -> readBaseline ref
       result `shouldBe` Right "round trip"
@@ -87,15 +89,9 @@
           mkRecord ref = FileRecord (hashContent "applied") "module" Template fixedTime ref Set.empty
           manifest :: Manifest
           manifest =
-            (emptyManifest fixedTime)
-              { files =
-                  Map.fromList
-                    [ ("a", mkRecord (Just first)),
-                      ("b", mkRecord (Just first)),
-                      ("c", mkRecord (Just second)),
-                      ("legacy", mkRecord Nothing)
-                    ]
-              }
+            ( (emptyManifest fixedTime)
+                & #files .~ Map.fromList [("a", mkRecord (Just first)), ("b", mkRecord (Just first)), ("c", mkRecord (Just second)), ("legacy", mkRecord Nothing)]
+            )
       manifestBaselineRefs manifest `shouldBe` Set.fromList [first, second]
 
 isStoreFailure :: Either BaselineError a -> Bool
diff --git a/test/Seihou/Engine/ConflictSpec.hs b/test/Seihou/Engine/ConflictSpec.hs
--- a/test/Seihou/Engine/ConflictSpec.hs
+++ b/test/Seihou/Engine/ConflictSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.ConflictSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Effectful
 import Seihou.Core.Types
@@ -52,7 +54,7 @@
     it "does not produce console output when force is True" $ do
       let conflicts = [mkConflict "a.txt"]
           (_result, st) = runPureEff $ runConsolePure [] $ resolveConflicts True conflicts
-      st.consoleOutputs `shouldBe` []
+      (st ^. #outputs) `shouldBe` []
 
   describe "resolveConflictsInteractive" $ do
     it "resolves accept with 'a'" $ do
@@ -87,7 +89,7 @@
       case result of
         Just [(_, res)] -> res `shouldBe` AcceptNew
         _ -> expectationFailure "Expected Just with one AcceptNew resolution"
-      any (T.isInfixOf "Invalid choice") (st.consoleOutputs) `shouldBe` True
+      any (T.isInfixOf "Invalid choice") (st ^. #outputs) `shouldBe` True
 
     it "resolves multiple files in order" $ do
       let conflicts = [mkConflict "a.txt", mkConflict "b.txt", mkConflict "c.txt"]
@@ -96,7 +98,7 @@
         Just resolved -> do
           length resolved `shouldBe` 3
           map snd resolved `shouldBe` [AcceptNew, KeepCurrent, Skip]
-          map ((.path) . fst) resolved `shouldBe` ["a.txt", "b.txt", "c.txt"]
+          map ((^. #path) . fst) resolved `shouldBe` ["a.txt", "b.txt", "c.txt"]
         Nothing -> expectationFailure "Expected Just, got Nothing"
 
     it "abort on second file stops prompting" $ do
@@ -104,7 +106,7 @@
           (result, st) = runPureEff $ runConsolePure ["a", "A"] $ resolveConflictsInteractive conflicts
       result `shouldBe` Nothing
       -- Should have prompted for a.txt and b.txt, but not c.txt
-      let outputs = T.unlines (st.consoleOutputs)
+      let outputs = T.unlines (st ^. #outputs)
       T.isInfixOf "a.txt" outputs `shouldBe` True
       T.isInfixOf "b.txt" outputs `shouldBe` True
       T.isInfixOf "c.txt" outputs `shouldBe` False
@@ -112,7 +114,7 @@
     it "outputs file paths in prompt messages" $ do
       let conflict = mkConflict "src/Main.hs"
           (_result, st) = runPureEff $ runConsolePure ["a"] $ resolveConflictsInteractive [conflict]
-          outputs = T.unlines (st.consoleOutputs)
+          outputs = T.unlines (st ^. #outputs)
       T.isInfixOf "src/Main.hs" outputs `shouldBe` True
       T.isInfixOf "modified since last generation" outputs `shouldBe` True
 
@@ -131,21 +133,21 @@
           map snd resolved `shouldBe` [KeepCurrent, AcceptNew]
         Nothing -> expectationFailure "Expected Just, got Nothing"
       -- Verify prompt output was produced
-      let outputs = T.unlines (st.consoleOutputs)
+      let outputs = T.unlines (st ^. #outputs)
       T.isInfixOf "config.yaml" outputs `shouldBe` True
       T.isInfixOf "Makefile" outputs `shouldBe` True
 
     it "non-interactive mode produces no console output" $ do
       let conflicts = [mkConflict "a.txt"]
           (_result, st) = runPureEff $ runConsolePureNonInteractive $ resolveConflicts False conflicts
-      st.consoleOutputs `shouldBe` []
+      (st ^. #outputs) `shouldBe` []
 
     it "force mode preserves conflict file references in resolution" $ do
       let c = mkConflict "important.txt"
           (result, _st) = runPureEff $ runConsolePure [] $ resolveConflicts True [c]
       case result of
         Just [(resolved_c, AcceptNew)] ->
-          resolved_c.path `shouldBe` "important.txt"
+          (resolved_c ^. #path) `shouldBe` "important.txt"
         _ -> expectationFailure "Expected Just with AcceptNew for important.txt"
 
     it "interactive abort via resolveConflicts returns Nothing" $ do
@@ -153,14 +155,14 @@
           (result, st) = runPureEff $ runConsolePure ["A"] $ resolveConflicts False conflicts
       result `shouldBe` Nothing
       -- Only first.txt was prompted before abort
-      let outputs = T.unlines (st.consoleOutputs)
+      let outputs = T.unlines (st ^. #outputs)
       T.isInfixOf "first.txt" outputs `shouldBe` True
       T.isInfixOf "second.txt" outputs `shouldBe` False
 
     it "choice prompt text includes all four options" $ do
       let conflict = mkConflict "test.txt"
           (_result, st) = runPureEff $ runConsolePure ["s"] $ resolveConflicts False [conflict]
-          outputs = T.unlines (st.consoleOutputs)
+          outputs = T.unlines (st ^. #outputs)
       T.isInfixOf "[a]ccept" outputs `shouldBe` True
       T.isInfixOf "[k]eep" outputs `shouldBe` True
       T.isInfixOf "[s]kip" outputs `shouldBe` True
diff --git a/test/Seihou/Engine/DiffSpec.hs b/test/Seihou/Engine/DiffSpec.hs
--- a/test/Seihou/Engine/DiffSpec.hs
+++ b/test/Seihou/Engine/DiffSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.DiffSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set (Set)
 import Data.Set qualified as Set
@@ -46,15 +48,15 @@
 manifestWithFiles recs =
   let base = emptyManifest fixedTime
    in Manifest
-        { version = base.version,
-          genAt = base.genAt,
-          modules = base.modules,
-          vars = base.vars,
+        { version = base ^. #version,
+          genAt = base ^. #genAt,
+          modules = base ^. #modules,
+          vars = base ^. #vars,
           files = recs,
-          applications = base.applications,
-          recipe = base.recipe,
-          blueprint = base.blueprint,
-          blueprintMigrations = base.blueprintMigrations
+          applications = base ^. #applications,
+          recipe = base ^. #recipe,
+          blueprint = base ^. #blueprint,
+          blueprintMigrations = base ^. #blueprintMigrations
         }
 
 spec :: Spec
@@ -66,25 +68,25 @@
       let manifest = emptyManifest fixedTime
           planned = [("README.md", "# Hello", modName, Nothing)]
           result = runDiff emptyFS manifest active planned
-      length (result.new) `shouldBe` 1
-      (head result.new).path `shouldBe` "README.md"
+      length (result ^. #new) `shouldBe` 1
+      ((head (result ^. #new)) ^. #path) `shouldBe` "README.md"
 
     it "classifies file in plan + on disk (not in manifest) as Conflict" $ do
       let manifest = emptyManifest fixedTime
           planned = [("README.md", "# Hello", modName, Nothing)]
           fs = PureFS (Map.singleton "README.md" "existing content") mempty
           result = runDiff fs manifest active planned
-      length (result.conflicts) `shouldBe` 1
-      (head result.conflicts).path `shouldBe` "README.md"
+      length (result ^. #conflicts) `shouldBe` 1
+      ((head (result ^. #conflicts)) ^. #path) `shouldBe` "README.md"
 
     it "classifies patch op on existing file (not in manifest) as New, not Conflict" $ do
       let manifest = emptyManifest fixedTime
           planned = [(".gitignore", ".claude/\n", modName, Just AppendSection)]
           fs = PureFS (Map.singleton ".gitignore" ".seihou/\n") mempty
           result = runDiff fs manifest active planned
-      length (result.conflicts) `shouldBe` 0
-      length (result.new) `shouldBe` 1
-      (head result.new).path `shouldBe` ".gitignore"
+      length (result ^. #conflicts) `shouldBe` 0
+      length (result ^. #new) `shouldBe` 1
+      ((head (result ^. #new)) ^. #path) `shouldBe` ".gitignore"
 
     it "classifies patch op on user-modified file (in manifest) as Modified, not Conflict" $ do
       let originalContent = "original"
@@ -94,9 +96,9 @@
           planned = [("config.txt", patchContent, modName, Just AppendSection)]
           fs = PureFS (Map.singleton "config.txt" userContent) mempty
           result = runDiff fs manifest active planned
-      length (result.conflicts) `shouldBe` 0
-      length (result.modified) `shouldBe` 1
-      (head result.modified).path `shouldBe` "config.txt"
+      length (result ^. #conflicts) `shouldBe` 0
+      length (result ^. #modified) `shouldBe` 1
+      ((head (result ^. #modified)) ^. #path) `shouldBe` "config.txt"
 
     it "classifies file in manifest + plan + disk (unchanged) as Unchanged" $ do
       let content = "# Hello World"
@@ -104,8 +106,8 @@
           planned = [("README.md", content, modName, Nothing)]
           fs = PureFS (Map.singleton "README.md" content) mempty
           result = runDiff fs manifest active planned
-      length (result.unchanged) `shouldBe` 1
-      head (result.unchanged) `shouldBe` "README.md"
+      length (result ^. #unchanged) `shouldBe` 1
+      head (result ^. #unchanged) `shouldBe` "README.md"
 
     it "classifies file in manifest + plan + disk (plan changed) as Modified" $ do
       let oldContent = "# Hello"
@@ -115,9 +117,9 @@
           -- Disk matches manifest (user didn't touch it)
           fs = PureFS (Map.singleton "README.md" oldContent) mempty
           result = runDiff fs manifest active planned
-      length (result.modified) `shouldBe` 1
-      (head result.modified).path `shouldBe` "README.md"
-      (head result.modified).newContent `shouldBe` newContent
+      length (result ^. #modified) `shouldBe` 1
+      ((head (result ^. #modified)) ^. #path) `shouldBe` "README.md"
+      ((head (result ^. #modified)) ^. #newContent) `shouldBe` newContent
 
     it "classifies file in manifest + plan + disk (user modified) as Conflict" $ do
       let originalContent = "# Hello"
@@ -128,54 +130,50 @@
           -- Disk was modified by user (doesn't match manifest)
           fs = PureFS (Map.singleton "README.md" userContent) mempty
           result = runDiff fs manifest active planned
-      length (result.conflicts) `shouldBe` 1
-      (head result.conflicts).path `shouldBe` "README.md"
-      (head result.conflicts).planContent `shouldBe` planContent
+      length (result ^. #conflicts) `shouldBe` 1
+      ((head (result ^. #conflicts)) ^. #path) `shouldBe` "README.md"
+      ((head (result ^. #conflicts)) ^. #planContent) `shouldBe` planContent
 
     it "classifies file in manifest only (on disk) as Orphaned" $ do
       let content = "orphaned content"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files = Map.singleton "old-file.txt" (mkRecord content)
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.singleton "old-file.txt" (mkRecord content)
+            )
           planned = [] :: [(FilePath, Text, ModuleName, Maybe PatchOp)] -- module no longer produces this file
           fs = PureFS (Map.singleton "old-file.txt" content) mempty
           result = runDiff fs manifest active planned
-      length (result.orphaned) `shouldBe` 1
-      (head result.orphaned).path `shouldBe` "old-file.txt"
+      length (result ^. #orphaned) `shouldBe` 1
+      ((head (result ^. #orphaned)) ^. #path) `shouldBe` "old-file.txt"
 
     it "classifies file in manifest only (not on disk) as Orphaned" $ do
       let content = "deleted content"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files = Map.singleton "deleted.txt" (mkRecord content)
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.singleton "deleted.txt" (mkRecord content)
+            )
           planned = [] :: [(FilePath, Text, ModuleName, Maybe PatchOp)]
           result = runDiff emptyFS manifest active planned
-      length (result.orphaned) `shouldBe` 1
-      (head result.orphaned).path `shouldBe` "deleted.txt"
+      length (result ^. #orphaned) `shouldBe` 1
+      ((head (result ^. #orphaned)) ^. #path) `shouldBe` "deleted.txt"
 
     it "classifies file in manifest + plan (deleted from disk) as Modified" $ do
       let content = "recreate me"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files = Map.singleton "gone.txt" (mkRecord content)
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.singleton "gone.txt" (mkRecord content)
+            )
           planned = [("gone.txt", "new version", modName, Nothing)]
           result = runDiff emptyFS manifest active planned
-      length (result.modified) `shouldBe` 1
-      (head result.modified).path `shouldBe` "gone.txt"
+      length (result ^. #modified) `shouldBe` 1
+      ((head (result ^. #modified)) ^. #path) `shouldBe` "gone.txt"
 
     it "handles mixed classifications" $ do
       let existingContent = "existing"
           manifest =
-            (emptyManifest fixedTime :: Manifest)
-              { files =
-                  Map.fromList
-                    [ ("unchanged.txt", mkRecord existingContent),
-                      ("orphaned.txt", mkRecord "orphan")
-                    ]
-              }
+            ( (emptyManifest fixedTime :: Manifest)
+                & #files .~ Map.fromList [("unchanged.txt", mkRecord existingContent), ("orphaned.txt", mkRecord "orphan")]
+            )
           planned =
             [ ("unchanged.txt", existingContent, modName, Nothing),
               ("new-file.txt", "brand new", modName, Nothing)
@@ -185,20 +183,20 @@
               (Map.fromList [("unchanged.txt", existingContent), ("orphaned.txt", "orphan")])
               mempty
           result = runDiff fs manifest active planned
-      length (result.new) `shouldBe` 1
-      length (result.unchanged) `shouldBe` 1
-      length (result.orphaned) `shouldBe` 1
-      length (result.modified) `shouldBe` 0
-      length (result.conflicts) `shouldBe` 0
+      length (result ^. #new) `shouldBe` 1
+      length (result ^. #unchanged) `shouldBe` 1
+      length (result ^. #orphaned) `shouldBe` 1
+      length (result ^. #modified) `shouldBe` 0
+      length (result ^. #conflicts) `shouldBe` 0
 
     it "handles empty manifest and empty plan" $ do
       let manifest = emptyManifest fixedTime
           result = runDiff emptyFS manifest Set.empty ([] :: [(FilePath, Text, ModuleName, Maybe PatchOp)])
-      result.new `shouldBe` []
-      result.modified `shouldBe` []
-      result.unchanged `shouldBe` []
-      result.conflicts `shouldBe` []
-      result.orphaned `shouldBe` []
+      (result ^. #new) `shouldBe` []
+      (result ^. #modified) `shouldBe` []
+      (result ^. #unchanged) `shouldBe` []
+      (result ^. #conflicts) `shouldBe` []
+      (result ^. #orphaned) `shouldBe` []
 
     it "does not classify files from inactive modules as orphaned" $ do
       let otherMod = ModuleName "other-module"
@@ -217,8 +215,8 @@
           activeModules = Set.singleton modName -- "test-module", NOT "other-module"
           fs = PureFS (Map.singleton "other.txt" content) mempty
           result = runDiff fs manifest activeModules planned
-      length (result.orphaned) `shouldBe` 0
-      length (result.new) `shouldBe` 1
+      length (result ^. #orphaned) `shouldBe` 0
+      length (result ^. #new) `shouldBe` 1
 
     it "classifies files from active modules as orphaned" $ do
       let content = "active module content"
@@ -227,8 +225,8 @@
           activeModules = Set.singleton modName -- file belongs to active module
           fs = PureFS (Map.singleton "old.txt" content) mempty
           result = runDiff fs manifest activeModules planned
-      length (result.orphaned) `shouldBe` 1
-      (head result.orphaned).path `shouldBe` "old.txt"
+      length (result ^. #orphaned) `shouldBe` 1
+      ((head (result ^. #orphaned)) ^. #path) `shouldBe` "old.txt"
 
     it "mixed active/inactive: only orphans active module's missing files" $ do
       let otherMod = ModuleName "other-module"
@@ -257,11 +255,11 @@
               mempty
           result = runDiff fs manifest activeModules planned
       -- active-old.txt is orphaned (active module no longer produces it)
-      length (result.orphaned) `shouldBe` 1
-      (head result.orphaned).path `shouldBe` "active-old.txt"
+      length (result ^. #orphaned) `shouldBe` 1
+      ((head (result ^. #orphaned)) ^. #path) `shouldBe` "active-old.txt"
       -- other.txt is invisible (inactive module), not orphaned
-      length (result.new) `shouldBe` 1
-      (head result.new).path `shouldBe` "active-new.txt"
+      length (result ^. #new) `shouldBe` 1
+      ((head (result ^. #new)) ^. #path) `shouldBe` "active-new.txt"
 
     it "plan targeting inactive module's file on disk is classified as Conflict" $ do
       let otherMod = ModuleName "other-module"
@@ -281,8 +279,8 @@
           fs = PureFS (Map.singleton "shared.txt" "other content") mempty
           result = runDiff fs manifest activeModules planned
       -- File exists on disk but not in active manifest → Conflict
-      length (result.conflicts) `shouldBe` 1
-      (head result.conflicts).path `shouldBe` "shared.txt"
+      length (result ^. #conflicts) `shouldBe` 1
+      ((head (result ^. #conflicts)) ^. #path) `shouldBe` "shared.txt"
 
     it "handles multiple active modules scoping independently" $ do
       let modA = ModuleName "module-a"
@@ -319,10 +317,10 @@
               mempty
           result = runDiff fs manifest activeModules planned
       -- from-a.txt unchanged (active, still produced)
-      length (result.unchanged) `shouldBe` 1
+      length (result ^. #unchanged) `shouldBe` 1
       -- from-b.txt orphaned (active module B no longer produces it)
-      length (result.orphaned) `shouldBe` 1
-      (head result.orphaned).path `shouldBe` "from-b.txt"
+      length (result ^. #orphaned) `shouldBe` 1
+      ((head (result ^. #orphaned)) ^. #path) `shouldBe` "from-b.txt"
       -- from-c.txt invisible (inactive module C)
-      length (result.new) `shouldBe` 0
-      length (result.conflicts) `shouldBe` 0
+      length (result ^. #new) `shouldBe` 0
+      length (result ^. #conflicts) `shouldBe` 0
diff --git a/test/Seihou/Engine/ExecuteSpec.hs b/test/Seihou/Engine/ExecuteSpec.hs
--- a/test/Seihou/Engine/ExecuteSpec.hs
+++ b/test/Seihou/Engine/ExecuteSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.ExecuteSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError)
@@ -45,7 +47,7 @@
       let ops = [WriteFileOp "hello.txt" "hello world" Template]
           (records, fs) = runExecFS emptyFS ops
       Map.member "hello.txt" records `shouldBe` True
-      Map.lookup "/project/hello.txt" (fs.files) `shouldBe` Just "hello world"
+      Map.lookup "/project/hello.txt" (fs ^. #files) `shouldBe` Just "hello world"
 
     it "creates a directory via CreateDirOp" $ do
       let ops = [CreateDirOp "src"]
@@ -62,19 +64,19 @@
           ops = [WriteFileOp "test.txt" content Template]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.txt"
-      record.hash `shouldBe` hashContent content
+      (record ^. #hash) `shouldBe` hashContent content
 
     it "produces FileRecord with correct module name" $ do
       let ops = [WriteFileOp "test.txt" "data" Template]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.txt"
-      record.moduleName `shouldBe` modName
+      (record ^. #moduleName) `shouldBe` modName
 
     it "produces FileRecord with correct timestamp" $ do
       let ops = [WriteFileOp "test.txt" "data" Template]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.txt"
-      record.generatedAt `shouldBe` fixedTime
+      (record ^. #generatedAt) `shouldBe` fixedTime
 
     it "handles multiple operations" $ do
       let ops =
@@ -84,8 +86,8 @@
             ]
           (records, fs) = runExecFS emptyFS ops
       Map.size records `shouldBe` 2
-      Map.lookup "/project/README.md" (fs.files) `shouldBe` Just "# Hello"
-      Map.lookup "/project/src/Main.hs" (fs.files) `shouldBe` Just "module Main where"
+      Map.lookup "/project/README.md" (fs ^. #files) `shouldBe` Just "# Hello"
+      Map.lookup "/project/src/Main.hs" (fs ^. #files) `shouldBe` Just "module Main where"
 
     it "skips RunCommandOp" $ do
       let ops = [RunCommandOp "echo hello" Nothing modName 0]
@@ -97,38 +99,38 @@
           ops = [CopyFileOp "/source/file.txt" "dest.txt"]
           (records, fs) = runExecFS initial ops
       Map.member "dest.txt" records `shouldBe` True
-      Map.lookup "/project/dest.txt" (fs.files) `shouldBe` Just "copied content"
+      Map.lookup "/project/dest.txt" (fs ^. #files) `shouldBe` Just "copied content"
 
     it "records Template strategy in FileRecord" $ do
       let ops = [WriteFileOp "test.txt" "content" Template]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.txt"
-      record.strategy `shouldBe` Template
+      (record ^. #strategy) `shouldBe` Template
 
     it "records Copy strategy in FileRecord" $ do
       let ops = [WriteFileOp "test.txt" "content" Copy]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.txt"
-      record.strategy `shouldBe` Copy
+      (record ^. #strategy) `shouldBe` Copy
 
     it "records DhallText strategy in FileRecord" $ do
       let ops = [WriteFileOp "test.txt" "content" DhallText]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.txt"
-      record.strategy `shouldBe` DhallText
+      (record ^. #strategy) `shouldBe` DhallText
 
     it "records Structured strategy in FileRecord" $ do
       let ops = [WriteFileOp "test.json" "{}" Structured]
           (records, _) = runExecFS emptyFS ops
           record = records Map.! "test.json"
-      record.strategy `shouldBe` Structured
+      (record ^. #strategy) `shouldBe` Structured
 
     it "executes PatchFileOp AppendFile on existing file" $ do
       let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty
           ops = [PatchFileOp "README.md" "extra line\n" AppendFile Template modName]
           (records, fs) = runExecFS initial ops
       Map.member "README.md" records `shouldBe` True
-      let content = fs.files Map.! "/project/README.md"
+      let content = (fs ^. #files) Map.! "/project/README.md"
       T.isInfixOf "# Title" content `shouldBe` True
       T.isInfixOf "extra line" content `shouldBe` True
 
@@ -137,7 +139,7 @@
           ops = [PatchFileOp "README.md" "header\n" PrependFile Template modName]
           (records, fs) = runExecFS initial ops
       Map.member "README.md" records `shouldBe` True
-      let content = fs.files Map.! "/project/README.md"
+      let content = (fs ^. #files) Map.! "/project/README.md"
       T.isInfixOf "header" content `shouldBe` True
       T.isInfixOf "# Title" content `shouldBe` True
 
@@ -146,7 +148,7 @@
           ops = [PatchFileOp "README.md" "section content\n" AppendSection Template modName]
           (records, fs) = runExecFS initial ops
       Map.member "README.md" records `shouldBe` True
-      let content = fs.files Map.! "/project/README.md"
+      let content = (fs ^. #files) Map.! "/project/README.md"
       T.isInfixOf "# Title" content `shouldBe` True
       T.isInfixOf "seihou:test-module" content `shouldBe` True
       T.isInfixOf "section content" content `shouldBe` True
@@ -155,7 +157,7 @@
       let ops = [PatchFileOp "new.txt" "new content\n" AppendFile Template modName]
           (records, fs) = runExecFS emptyFS ops
       Map.member "new.txt" records `shouldBe` True
-      let content = fs.files Map.! "/project/new.txt"
+      let content = (fs ^. #files) Map.! "/project/new.txt"
       T.isInfixOf "new content" content `shouldBe` True
 
     it "executes PatchFileOp AppendLineIfAbsent, skipping existing lines" $ do
@@ -163,7 +165,7 @@
           ops = [PatchFileOp ".gitignore" ".env\n.claude/\n" AppendLineIfAbsent Template modName]
           (records, fs) = runExecFS initial ops
       Map.member ".gitignore" records `shouldBe` True
-      let content = fs.files Map.! "/project/.gitignore"
+      let content = (fs ^. #files) Map.! "/project/.gitignore"
       content `shouldBe` "node_modules/\n.env\n.claude/\n"
 
     it "executes PatchFileOp AppendLineIfAbsent idempotently" $ do
@@ -171,7 +173,7 @@
           ops = [PatchFileOp ".gitignore" ".claude/\n" AppendLineIfAbsent Template modName]
           (records, fs) = runExecFS initial ops
       Map.member ".gitignore" records `shouldBe` True
-      let content = fs.files Map.! "/project/.gitignore"
+      let content = (fs ^. #files) Map.! "/project/.gitignore"
       content `shouldBe` "node_modules/\n.claude/\n"
 
   describe "dryRunPlan" $ do
diff --git a/test/Seihou/Engine/MigrateSpec.hs b/test/Seihou/Engine/MigrateSpec.hs
--- a/test/Seihou/Engine/MigrateSpec.hs
+++ b/test/Seihou/Engine/MigrateSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.MigrateSpec (tests) where
 
+import Control.Lens (to, (&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -55,31 +57,8 @@
 mkManifest :: [(FilePath, Text)] -> Manifest
 mkManifest entries =
   (emptyManifest fixedTime)
-    { modules =
-        [ AppliedModule
-            { name = modName,
-              parentVars = emptyParentVars,
-              source = "/installed/demo",
-              moduleVersion = Just "1.0.0",
-              appliedAt = fixedTime,
-              removal = Nothing
-            }
-        ],
-      files =
-        Map.fromList
-          [ ( path,
-              FileRecord
-                { hash = hashContent content,
-                  moduleName = modName,
-                  strategy = Template,
-                  generatedAt = fixedTime,
-                  baseline = Nothing,
-                  applicationIds = mempty
-                }
-            )
-          | (path, content) <- entries
-          ]
-    }
+    & #modules .~ [AppliedModule {name = modName, parentVars = emptyParentVars, origin = LocalOrigin "demo", moduleVersion = Just "1.0.0", appliedAt = fixedTime, removal = Nothing}]
+    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty}) | (path, content) <- entries]
 
 -- | Build an in-memory filesystem from (path, content) pairs.
 mkFS :: [(FilePath, Text)] -> PureFS
@@ -89,10 +68,10 @@
 chain1 :: Text -> Text -> [MigrationOp] -> MigrationPlan
 chain1 fromV toV ops =
   MigrationPlan
-    { planModule = "demo",
-      planFrom = mkV fromV,
-      planTo = mkV toV,
-      planSteps = [Migration {from = fromV, to = toV, ops}]
+    { module_ = "demo",
+      from = mkV fromV,
+      to = mkV toV,
+      steps = [Migration {from = fromV, to = toV, ops}]
     }
 
 runClassifyResult :: PureFS -> Manifest -> MigrationPlan -> Either MigrationExecError ExecutedMigrationPlan
@@ -132,21 +111,21 @@
           fs = mkFS [("app/Main.hs", "module Main where")]
           c = chain1 "1.0.0" "2.0.0" [MoveFile "app/Main.hs" "src/Main.hs"]
           plan = runClassify fs manifest c
-      plan.planOps `shouldBe` [MoveFileInst "app/Main.hs" "src/Main.hs" MFSafe]
+      (plan ^. #ops) `shouldBe` [MoveFileInst "app/Main.hs" "src/Main.hs" MFSafe]
 
     it "marks a move-file as conflict when disk content differs" $ do
       let manifest = mkManifest [("app/Main.hs", "original")]
           fs = mkFS [("app/Main.hs", "user-edited")]
           c = chain1 "1.0.0" "2.0.0" [MoveFile "app/Main.hs" "src/Main.hs"]
           plan = runClassify fs manifest c
-      plan.planOps `shouldBe` [MoveFileInst "app/Main.hs" "src/Main.hs" MFConflict]
+      (plan ^. #ops) `shouldBe` [MoveFileInst "app/Main.hs" "src/Main.hs" MFConflict]
 
     it "marks a delete-file as gone when the file is absent" $ do
       let manifest = mkManifest [("Setup.hs", "boring")]
           fs = mkFS [] -- file already deleted on disk
           c = chain1 "1.0.0" "2.0.0" [DeleteFile "Setup.hs"]
           plan = runClassify fs manifest c
-      plan.planOps `shouldBe` [DeleteFileInst "Setup.hs" MFGone]
+      (plan ^. #ops) `shouldBe` [DeleteFileInst "Setup.hs" MFGone]
 
     it "rejects a delete-dir path with a parent directory segment" $ do
       let manifest = mkManifest []
@@ -178,12 +157,12 @@
           (result, fs') = runExecute fs manifest plan False
       case result of
         Right m -> do
-          Map.member "src/Main.hs" m.files `shouldBe` True
-          Map.member "app/Main.hs" m.files `shouldBe` False
-          (head m.modules).moduleVersion `shouldBe` Just "2.0.0"
+          Map.member "src/Main.hs" (m ^. #files) `shouldBe` True
+          Map.member "app/Main.hs" (m ^. #files) `shouldBe` False
+          ((head (m ^. #modules)) ^. #moduleVersion) `shouldBe` Just "2.0.0"
         Left err -> expectationFailure ("expected Right, got: " <> show err)
-      Map.member "src/Main.hs" fs'.files `shouldBe` True
-      Map.member "app/Main.hs" fs'.files `shouldBe` False
+      Map.member "src/Main.hs" (fs' ^. #files) `shouldBe` True
+      Map.member "app/Main.hs" (fs' ^. #files) `shouldBe` False
 
     it "refuses on conflict without --force and leaves disk untouched" $ do
       let manifest = mkManifest [("app/Main.hs", "original")]
@@ -193,8 +172,8 @@
           (result, fs') = runExecute fs manifest plan False
       result `shouldBe` Left (MigrationConflict ["app/Main.hs"])
       -- Disk untouched: original src still there, dest absent.
-      Map.member "app/Main.hs" fs'.files `shouldBe` True
-      Map.member "src/Main.hs" fs'.files `shouldBe` False
+      Map.member "app/Main.hs" (fs' ^. #files) `shouldBe` True
+      Map.member "src/Main.hs" (fs' ^. #files) `shouldBe` False
 
     it "executes through a conflict when force is set" $ do
       let manifest = mkManifest [("app/Main.hs", "original")]
@@ -204,12 +183,12 @@
           (result, fs') = runExecute fs manifest plan True
       case result of
         Right m -> do
-          Map.member "src/Main.hs" m.files `shouldBe` True
-          Map.member "app/Main.hs" m.files `shouldBe` False
+          Map.member "src/Main.hs" (m ^. #files) `shouldBe` True
+          Map.member "app/Main.hs" (m ^. #files) `shouldBe` False
         Left err -> expectationFailure ("expected Right, got: " <> show err)
       -- The user-edited content rode along: the move is a key rename in
       -- the pure FS, so the bytes follow the rename.
-      Map.lookup "src/Main.hs" fs'.files `shouldBe` Just "user-edited"
+      Map.lookup "src/Main.hs" (fs' ^. #files) `shouldBe` Just "user-edited"
 
     it "moves a directory, rewriting all contained manifest entries" $ do
       let manifest =
@@ -227,9 +206,9 @@
           (result, fs') = runExecute fs manifest plan False
       case result of
         Right m -> do
-          Map.keys m.files `shouldMatchList` ["src/Main.hs", "src/Lib.hs"]
+          Map.keys (m ^. #files) `shouldMatchList` ["src/Main.hs", "src/Lib.hs"]
         Left err -> expectationFailure ("expected Right, got: " <> show err)
-      Map.keys fs'.files `shouldMatchList` ["src/Main.hs", "src/Lib.hs"]
+      Map.keys (fs' ^. #files) `shouldMatchList` ["src/Main.hs", "src/Lib.hs"]
 
     it "is a no-op for a delete-file whose target is already gone" $ do
       let manifest = mkManifest [("Setup.hs", "boring")]
@@ -238,9 +217,9 @@
           plan = runClassify fs manifest c
           (result, fs') = runExecute fs manifest plan False
       case result of
-        Right m -> Map.member "Setup.hs" m.files `shouldBe` False
+        Right m -> Map.member "Setup.hs" (m ^. #files) `shouldBe` False
         Left err -> expectationFailure ("expected Right, got: " <> show err)
-      Map.null fs'.files `shouldBe` True
+      Map.null (fs' ^. #files) `shouldBe` True
 
     it "deletes a directory and drops every manifest entry under it" $ do
       let manifest =
@@ -259,9 +238,9 @@
           plan = runClassify fs manifest c
           (result, fs') = runExecute fs manifest plan False
       case result of
-        Right m -> Map.keys m.files `shouldBe` ["keep.hs"]
+        Right m -> Map.keys (m ^. #files) `shouldBe` ["keep.hs"]
         Left err -> expectationFailure ("expected Right, got: " <> show err)
-      Map.keys fs'.files `shouldBe` ["keep.hs"]
+      Map.keys (fs' ^. #files) `shouldBe` ["keep.hs"]
 
     it "applies a chain of two migrations in declaration order" $ do
       -- 1.0.0 → 2.0.0: move app → src
@@ -270,10 +249,10 @@
           fs = mkFS [("app/Main.hs", "x")]
           chain =
             MigrationPlan
-              { planModule = "demo",
-                planFrom = mkV "1.0.0",
-                planTo = mkV "3.0.0",
-                planSteps =
+              { module_ = "demo",
+                from = mkV "1.0.0",
+                to = mkV "3.0.0",
+                steps =
                   [ Migration "1.0.0" "2.0.0" [MoveDir "app" "src"],
                     Migration "2.0.0" "3.0.0" [DeleteFile "src/Main.hs"]
                   ]
@@ -282,7 +261,7 @@
           (result, fs') = runExecute fs manifest plan False
       case result of
         Right m -> do
-          Map.null m.files `shouldBe` True
-          (head m.modules).moduleVersion `shouldBe` Just "3.0.0"
+          Map.null (m ^. #files) `shouldBe` True
+          ((head (m ^. #modules)) ^. #moduleVersion) `shouldBe` Just "3.0.0"
         Left err -> expectationFailure ("expected Right, got: " <> show err)
-      Map.null fs'.files `shouldBe` True
+      Map.null (fs' ^. #files) `shouldBe` True
diff --git a/test/Seihou/Engine/PlanSpec.hs b/test/Seihou/Engine/PlanSpec.hs
--- a/test/Seihou/Engine/PlanSpec.hs
+++ b/test/Seihou/Engine/PlanSpec.hs
@@ -1,6 +1,7 @@
 module Seihou.Engine.PlanSpec (tests) where
 
 import Data.Aeson qualified as Aeson
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Data.Text.Encoding qualified as T
@@ -538,28 +539,28 @@
             Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
             Right ops -> do
               -- Should have operations for README, src/Lib.hs, LICENSE, my-app.cabal, and cabal.project
-              let writeOps = [op | op@(WriteFileOp _ _ _) <- ops]
-                  dirOps = [op | op@(CreateDirOp _) <- ops]
+              let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
+                  dirPaths = [path | CreateDirOp {path} <- ops]
               length writeOps `shouldBe` 5
               -- README.md with rendered content
-              (writeOps !! 0).dest `shouldBe` "README.md"
-              T.isInfixOf "my-app" ((writeOps !! 0).content) `shouldBe` True
+              fst (writeOps !! 0) `shouldBe` "README.md"
+              T.isInfixOf "my-app" (snd (writeOps !! 0)) `shouldBe` True
               -- src/Lib.hs
-              (writeOps !! 1).dest `shouldBe` "src/Lib.hs"
+              fst (writeOps !! 1) `shouldBe` "src/Lib.hs"
               -- LICENSE (copy)
-              (writeOps !! 2).dest `shouldBe` "LICENSE"
+              fst (writeOps !! 2) `shouldBe` "LICENSE"
               -- my-app.cabal (dest expanded from {{project.name}}.cabal)
-              (writeOps !! 3).dest `shouldBe` "my-app.cabal"
-              T.isInfixOf "my-app" ((writeOps !! 3).content) `shouldBe` True
+              fst (writeOps !! 3) `shouldBe` "my-app.cabal"
+              T.isInfixOf "my-app" (snd (writeOps !! 3)) `shouldBe` True
               -- cabal.project (DhallText)
-              (writeOps !! 4).dest `shouldBe` "cabal.project"
-              T.isInfixOf "my-app" ((writeOps !! 4).content) `shouldBe` True
+              fst (writeOps !! 4) `shouldBe` "cabal.project"
+              T.isInfixOf "my-app" (snd (writeOps !! 4)) `shouldBe` True
               -- Should have CreateDirOp for src/
-              dirOps `shouldSatisfy` any (\op -> op.path == "src")
+              dirPaths `shouldSatisfy` elem "src"
               -- Should have RunCommandOp for the command
-              let cmdOps = [op | op@RunCommandOp {} <- ops]
+              let cmdOps = [command | RunCommandOp {command} <- ops]
               length cmdOps `shouldBe` 1
-              (cmdOps !! 0).command `shouldBe` "echo 'Project generated'"
+              (cmdOps !! 0) `shouldBe` "echo 'Project generated'"
 
     it "compiles a Template step with patch = AppendFile to PatchFileOp" $ do
       withFixture [("section.tpl", "appended content")] $ \baseDir -> do
@@ -747,12 +748,9 @@
         result <- compilePlan baseDir modul vars
         case result of
           Right ops -> do
-            let cmdOps = [op | op@RunCommandOp {} <- ops]
+            let cmdOps = [(command, workDir, moduleName, occurrence) | RunCommandOp {command, workDir, moduleName, occurrence} <- ops]
             length cmdOps `shouldBe` 1
-            (cmdOps !! 0).command `shouldBe` "echo hello"
-            (cmdOps !! 0).workDir `shouldBe` Nothing
-            (cmdOps !! 0).moduleName `shouldBe` "test"
-            (cmdOps !! 0).occurrence `shouldBe` 0
+            cmdOps `shouldBe` [("echo hello", Nothing, "test", 0)]
           Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "numbers only identical rendered commands from the same module" $ do
@@ -778,7 +776,7 @@
         result <- compilePlan baseDir modul Map.empty
         case result of
           Right ops -> do
-            let commandOccurrences = [(op.command, op.occurrence) | op@RunCommandOp {} <- ops]
+            let commandOccurrences = [(command, occurrence) | RunCommandOp {command, occurrence} <- ops]
             commandOccurrences
               `shouldBe` [("echo same", 0), ("echo other", 0), ("echo same", 1)]
           Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
@@ -884,9 +882,9 @@
         result <- compilePlan baseDir modul vars
         case result of
           Right ops -> do
-            let cmdOps = [op | op@RunCommandOp {} <- ops]
+            let cmdOps = [workDir | RunCommandOp {workDir} <- ops]
             length cmdOps `shouldBe` 1
-            (cmdOps !! 0).workDir `shouldBe` Just "subdir"
+            (cmdOps !! 0) `shouldBe` Just "subdir"
           Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "interpolates {{var}} in command run field" $ do
@@ -909,9 +907,9 @@
         result <- compilePlan baseDir modul vars
         case result of
           Right ops -> do
-            let cmdOps = [op | op@RunCommandOp {} <- ops]
+            let cmdOps = [command | RunCommandOp {command} <- ops]
             length cmdOps `shouldBe` 1
-            (cmdOps !! 0).command `shouldBe` "echo my-app"
+            (cmdOps !! 0) `shouldBe` "echo my-app"
           Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "interpolates {{var}} in command workDir field" $ do
@@ -934,9 +932,9 @@
         result <- compilePlan baseDir modul vars
         case result of
           Right ops -> do
-            let cmdOps = [op | op@RunCommandOp {} <- ops]
+            let cmdOps = [workDir | RunCommandOp {workDir} <- ops]
             length cmdOps `shouldBe` 1
-            (cmdOps !! 0).workDir `shouldBe` Just "my-app"
+            (cmdOps !! 0) `shouldBe` Just "my-app"
           Left errs -> expectationFailure ("Expected Right, got: " <> show errs)
 
     it "rejects a rendered command workDir with a parent directory segment" $ do
diff --git a/test/Seihou/Engine/PreviewSpec.hs b/test/Seihou/Engine/PreviewSpec.hs
--- a/test/Seihou/Engine/PreviewSpec.hs
+++ b/test/Seihou/Engine/PreviewSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.PreviewSpec (tests) where
 
+import Control.Lens ((&), (.~))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Core.Types
@@ -39,56 +41,49 @@
             ]
           result = buildPreview ops Nothing Map.empty
       length result `shouldBe` 3
-      (result !! 0).previewStatus `shouldBe` FsNew
-      (result !! 1).previewStatus `shouldBe` FsNew
-      (result !! 2).previewStatus `shouldBe` FsNew
+      [st | FilePreview {status = st} <- result] `shouldBe` [FsNew, FsNew, FsNew]
 
     it "classifies a new file as FsNew" $ do
       let ops = [WriteFileOp "README.md" "# Hello" Template]
-          diff = emptyDiff {new = [PlannedFile "README.md" modName "# Hello"]}
+          diff = (emptyDiff & #new .~ [PlannedFile "README.md" modName "# Hello"])
           result = buildPreview ops (Just diff) Map.empty
       length result `shouldBe` 1
-      (head result).previewStatus `shouldBe` FsNew
+      [st | FilePreview {status = st} <- result] `shouldBe` [FsNew]
 
     it "classifies a modified file as FsModified" $ do
       let ops = [WriteFileOp "README.md" "# Updated" Template]
-          diff = emptyDiff {modified = [ModifiedFile "README.md" modName (SHA256 "old") "# Updated"]}
+          diff =
+            ( emptyDiff
+                & #modified .~ [ModifiedFile "README.md" modName (SHA256 "old") "# Updated"]
+            )
           result = buildPreview ops (Just diff) Map.empty
       length result `shouldBe` 1
-      (head result).previewStatus `shouldBe` FsModified
+      [st | FilePreview {status = st} <- result] `shouldBe` [FsModified]
 
     it "classifies an unchanged file as FsUnchanged" $ do
       let ops = [WriteFileOp "README.md" "# Same" Template]
-          diff = emptyDiff {unchanged = ["README.md"]}
+          diff = (emptyDiff & #unchanged .~ ["README.md"])
           result = buildPreview ops (Just diff) Map.empty
       length result `shouldBe` 1
-      (head result).previewStatus `shouldBe` FsUnchanged
+      [st | FilePreview {status = st} <- result] `shouldBe` [FsUnchanged]
 
     it "classifies a conflicting file as FsConflict" $ do
       let ops = [WriteFileOp "README.md" "# New" Template]
           diff =
-            emptyDiff
-              { conflicts =
-                  [ ConflictFile
-                      { path = "README.md",
-                        moduleName = modName,
-                        manifestHash = SHA256 "man",
-                        diskHash = SHA256 "disk",
-                        planContent = "# New"
-                      }
-                  ]
-              }
+            ( emptyDiff
+                & #conflicts .~ [ConflictFile {path = "README.md", moduleName = modName, manifestHash = SHA256 "man", diskHash = SHA256 "disk", planContent = "# New"}]
+            )
           result = buildPreview ops (Just diff) Map.empty
       length result `shouldBe` 1
-      (head result).previewStatus `shouldBe` FsConflict
+      [st | FilePreview {status = st} <- result] `shouldBe` [FsConflict]
 
     it "classifies an orphaned file as FsOrphaned" $ do
       let ops = [WriteFileOp "other.txt" "content" Template]
           diff =
-            emptyDiff
-              { new = [PlannedFile "other.txt" modName "content"],
-                orphaned = [OrphanedFile "old.txt" modName]
-              }
+            ( emptyDiff
+                & #new .~ [PlannedFile "other.txt" modName "content"]
+                & #orphaned .~ [OrphanedFile "old.txt" modName]
+            )
           result = buildPreview ops (Just diff) Map.empty
       -- One file preview + one orphan preview
       length result `shouldBe` 2
@@ -102,10 +97,10 @@
     it "does not include orphaned files that are produced by an operation" $ do
       let ops = [WriteFileOp "reused.txt" "content" Template]
           diff =
-            emptyDiff
-              { new = [PlannedFile "reused.txt" modName "content"],
-                orphaned = [OrphanedFile "reused.txt" modName2]
-              }
+            ( emptyDiff
+                & #new .~ [PlannedFile "reused.txt" modName "content"]
+                & #orphaned .~ [OrphanedFile "reused.txt" modName2]
+            )
           result = buildPreview ops (Just diff) Map.empty
       -- Only the file preview, orphan is suppressed because path matches an operation
       length result `shouldBe` 1
@@ -174,7 +169,7 @@
               WriteFileOp "d.txt" "" Structured
             ]
           result = buildPreview ops Nothing Map.empty
-      map (.previewAnnotation) (filter isFilePreview result)
+      [a | FilePreview {annotation = a} <- result]
         `shouldBe` ["copy", "template", "dhall-text", "structured"]
 
   describe "renderPreviewPlain" $ do
@@ -214,7 +209,7 @@
   describe "formatPlanView" $ do
     it "includes header with module names" $ do
       let preview = [FilePreview FsNew "README.md" "template" (Just modName)]
-          diff = emptyDiff {new = [PlannedFile "README.md" modName "# Hello"]}
+          diff = (emptyDiff & #new .~ [PlannedFile "README.md" modName "# Hello"])
           rendered = formatPlanView [modName] Map.empty preview diff
       T.isInfixOf "Generation Plan (test-module):" rendered `shouldBe` True
 
@@ -239,10 +234,10 @@
 
     it "includes summary with file and conflict counts" $ do
       let diff =
-            emptyDiff
-              { new = [PlannedFile "a.txt" modName ""],
-                modified = [ModifiedFile "b.txt" modName (SHA256 "old") "new"]
-              }
+            ( emptyDiff
+                & #new .~ [PlannedFile "a.txt" modName ""]
+                & #modified .~ [ModifiedFile "b.txt" modName (SHA256 "old") "new"]
+            )
           rendered = formatPlanView [modName] Map.empty [] diff
       T.isInfixOf "2 files to write, 0 conflicts" rendered `shouldBe` True
 
diff --git a/test/Seihou/Engine/ReconcileSpec.hs b/test/Seihou/Engine/ReconcileSpec.hs
--- a/test/Seihou/Engine/ReconcileSpec.hs
+++ b/test/Seihou/Engine/ReconcileSpec.hs
@@ -1,6 +1,8 @@
 module Seihou.Engine.ReconcileSpec (tests) where
 
+import Control.Lens ((^.))
 import Data.Functor.Identity (Identity (..))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -31,9 +33,9 @@
           result = planWith (Map.singleton ".gitignore" "root\n") Map.empty Map.empty empty [appA] operations (owners ".gitignore" [appA]) cleanMerge
       case result of
         Right reconciliation -> do
-          Map.size reconciliation.files `shouldBe` 1
-          case reconciliation.files Map.! ".gitignore" of
-            FileUpdate desired _ _ _ -> desired.generatedContent `shouldBe` "root\none\ntwo\nthree\n"
+          Map.size (reconciliation ^. #files) `shouldBe` 1
+          case (reconciliation ^. #files) Map.! ".gitignore" of
+            FileUpdate desired _ _ _ -> (desired ^. #generatedContent) `shouldBe` "root\none\ntwo\nthree\n"
             other -> expectationFailure ("expected one update, got " <> show other)
         Left err -> expectationFailure (show err)
 
@@ -44,8 +46,8 @@
             ]
           result = planWith Map.empty Map.empty Map.empty empty [appA] operations (owners "README.md" [appA]) cleanMerge
       case result of
-        Right reconciliation -> case reconciliation.files Map.! "README.md" of
-          FileCreate desired _ _ -> desired.generatedContent `shouldBe` "generated\nadded\n"
+        Right reconciliation -> case (reconciliation ^. #files) Map.! "README.md" of
+          FileCreate desired _ _ -> (desired ^. #generatedContent) `shouldBe` "generated\nadded\n"
           other -> expectationFailure ("expected create, got " <> show other)
         Left err -> expectationFailure (show err)
 
@@ -62,10 +64,10 @@
               (owners "copied.txt" [appA])
               cleanMerge
       case result of
-        Right reconciliation -> case reconciliation.files Map.! "copied.txt" of
+        Right reconciliation -> case (reconciliation ^. #files) Map.! "copied.txt" of
           FileCreate desired _ _ -> do
-            desired.generatedContent `shouldBe` "copied\n"
-            desired.strategy `shouldBe` Copy
+            (desired ^. #generatedContent) `shouldBe` "copied\n"
+            (desired ^. #strategy) `shouldBe` Copy
           other -> expectationFailure ("expected create, got " <> show other)
         Left err -> expectationFailure (show err)
 
@@ -83,7 +85,7 @@
               (owners "legacy.txt" [appA])
               cleanMerge
       case result of
-        Right reconciliation -> case reconciliation.files Map.! "legacy.txt" of
+        Right reconciliation -> case (reconciliation ^. #files) Map.! "legacy.txt" of
           FileUpdate _ _ _ _ -> pure ()
           other -> expectationFailure ("expected trusted update, got " <> show other)
         Left err -> expectationFailure (show err)
@@ -101,7 +103,7 @@
               (owners "legacy.txt" [appA])
               cleanMerge
       case result of
-        Right reconciliation -> case reconciliation.files Map.! "legacy.txt" of
+        Right reconciliation -> case (reconciliation ^. #files) Map.! "legacy.txt" of
           FileConflict _ current _ MissingTrustedBaseline _ _ Nothing -> current `shouldBe` "user edit\n"
           other -> expectationFailure ("expected conservative conflict, got " <> show other)
         Left err -> expectationFailure (show err)
@@ -164,11 +166,11 @@
               (owners "file.txt" [appA])
               cleanMerge
       case result of
-        Right reconciliation -> case reconciliation.files Map.! "file.txt" of
+        Right reconciliation -> case (reconciliation ^. #files) Map.! "file.txt" of
           FileUnchanged _ state _ _ -> do
-            state.appliedContent `shouldBe` "user\n"
-            state.recordedHash `shouldBe` oldRecord.hash
-            state.writeToDisk `shouldBe` False
+            (state ^. #appliedContent) `shouldBe` "user\n"
+            (state ^. #recordedHash) `shouldBe` (oldRecord ^. #hash)
+            (state ^. #writeToDisk) `shouldBe` False
           other -> expectationFailure ("expected unchanged user edit, got " <> show other)
         Left err -> expectationFailure (show err)
 
@@ -188,8 +190,8 @@
               (\_ _ _ -> MergeClean "user and generated\n")
       case result of
         Right reconciliation -> do
-          case reconciliation.files Map.! "file.txt" of
-            FileAutoMerge _ state _ _ -> state.appliedContent `shouldBe` "user and generated\n"
+          case (reconciliation ^. #files) Map.! "file.txt" of
+            FileAutoMerge _ state _ _ -> (state ^. #appliedContent) `shouldBe` "user and generated\n"
             other -> expectationFailure ("expected automatic merge, got " <> show other)
           reconciliationSummary reconciliation `shouldBe` ReconciliationSummary 0 0 1 0 0 0 0 0
         Left err -> expectationFailure (show err)
@@ -216,10 +218,10 @@
           let resolved = resolveFileConflict "file.txt" KeepCurrent reconciliation
           case resolved of
             Left err -> expectationFailure (show err)
-            Right finalPlan -> case finalPlan.files Map.! "file.txt" of
+            Right finalPlan -> case (finalPlan ^. #files) Map.! "file.txt" of
               FileConflict _ _ _ _ _ _ (Just resolution) -> do
-                resolution.state.generatedBaseline `shouldBe` "generated\n"
-                resolution.state.appliedContent `shouldBe` "user\n"
+                (resolution ^. #state . #generatedBaseline) `shouldBe` "generated\n"
+                (resolution ^. #state . #appliedContent) `shouldBe` "user\n"
                 unresolvedPaths finalPlan `shouldBe` Set.empty
               other -> expectationFailure ("expected resolved conflict, got " <> show other)
 
@@ -239,9 +241,9 @@
       case result of
         Left err -> expectationFailure (show err)
         Right reconciliation -> do
-          reconciliation.files Map.! "safe.txt" `shouldSatisfy` isSafeDelete
-          reconciliation.files Map.! "edited.txt" `shouldSatisfy` isEditedOrphan
-          reconciliation.files Map.! "shared.txt" `shouldSatisfy` isSharedRelease
+          (reconciliation ^. #files) Map.! "safe.txt" `shouldSatisfy` isSafeDelete
+          (reconciliation ^. #files) Map.! "edited.txt" `shouldSatisfy` isEditedOrphan
+          (reconciliation ^. #files) Map.! "shared.txt" `shouldSatisfy` isSharedRelease
           unresolvedPaths reconciliation `shouldBe` Set.singleton "edited.txt"
           case resolveEditedOrphan "edited.txt" RetainTrackedOrphan reconciliation of
             Left err -> expectationFailure (show err)
@@ -260,15 +262,15 @@
 withFile :: FilePath -> FileRecord -> Manifest -> Manifest
 withFile path fileRecord manifest =
   Manifest
-    { version = manifest.version,
-      genAt = manifest.genAt,
-      modules = manifest.modules,
-      vars = manifest.vars,
-      files = Map.insert path fileRecord manifest.files,
-      applications = manifest.applications,
-      recipe = manifest.recipe,
-      blueprint = manifest.blueprint,
-      blueprintMigrations = manifest.blueprintMigrations
+    { version = manifest ^. #version,
+      genAt = manifest ^. #genAt,
+      modules = manifest ^. #modules,
+      vars = manifest ^. #vars,
+      files = Map.insert path fileRecord (manifest ^. #files),
+      applications = manifest ^. #applications,
+      recipe = manifest ^. #recipe,
+      blueprint = manifest ^. #blueprint,
+      blueprintMigrations = manifest ^. #blueprintMigrations
     }
 
 record :: Text -> Maybe BaselineRef -> [ApplicationId] -> FileRecord
diff --git a/test/Seihou/Engine/RemoveSpec.hs b/test/Seihou/Engine/RemoveSpec.hs
--- a/test/Seihou/Engine/RemoveSpec.hs
+++ b/test/Seihou/Engine/RemoveSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.RemoveSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -33,61 +35,15 @@
 mkManifest :: Bool -> [(FilePath, Text)] -> Manifest
 mkManifest isRemovable fileContents =
   (emptyManifest fixedTime)
-    { modules =
-        [ AppliedModule
-            { name = modName,
-              parentVars = emptyParentVars,
-              source = "/path/to/test-module",
-              moduleVersion = Nothing,
-              appliedAt = fixedTime,
-              removal = if isRemovable then Just (Removal [] []) else Nothing
-            }
-        ],
-      files =
-        Map.fromList
-          [ ( path,
-              FileRecord
-                { hash = hashContent content,
-                  moduleName = modName,
-                  strategy = Template,
-                  generatedAt = fixedTime,
-                  baseline = Nothing,
-                  applicationIds = mempty
-                }
-            )
-          | (path, content) <- fileContents
-          ]
-    }
+    & #modules .~ [AppliedModule {name = modName, parentVars = emptyParentVars, origin = LocalOrigin "test-module", moduleVersion = Nothing, appliedAt = fixedTime, removal = if isRemovable then Just (Removal [] []) else Nothing}]
+    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty}) | (path, content) <- fileContents]
 
 -- | Helper: create a manifest with a specific removal spec.
 mkManifestWithRemoval :: Removal -> [(FilePath, Text)] -> Manifest
 mkManifestWithRemoval removal fileContents =
   (emptyManifest fixedTime)
-    { modules =
-        [ AppliedModule
-            { name = modName,
-              parentVars = emptyParentVars,
-              source = "/path/to/test-module",
-              moduleVersion = Nothing,
-              appliedAt = fixedTime,
-              removal = Just removal
-            }
-        ],
-      files =
-        Map.fromList
-          [ ( path,
-              FileRecord
-                { hash = hashContent content,
-                  moduleName = modName,
-                  strategy = Template,
-                  generatedAt = fixedTime,
-                  baseline = Nothing,
-                  applicationIds = mempty
-                }
-            )
-          | (path, content) <- fileContents
-          ]
-    }
+    & #modules .~ [AppliedModule {name = modName, parentVars = emptyParentVars, origin = LocalOrigin "test-module", moduleVersion = Nothing, appliedAt = fixedTime, removal = Just removal}]
+    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty}) | (path, content) <- fileContents]
 
 -- | Helper: create a PureFS with files.
 mkFS :: [(FilePath, Text)] -> PureFS
@@ -141,7 +97,7 @@
           result = runPlan fs manifest modName
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.files `shouldBe` [RemovalSafe "README.md"]
+        Right plan -> (plan ^. #files) `shouldBe` [RemovalSafe "README.md"]
 
     it "classifies user-modified files as RemovalConflict" $ do
       let manifest = mkManifest True [("README.md", "original")]
@@ -149,14 +105,14 @@
           result = runPlan fs manifest modName
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.files `shouldBe` [RemovalConflict "README.md"]
+        Right plan -> (plan ^. #files) `shouldBe` [RemovalConflict "README.md"]
 
     it "classifies deleted files as RemovalGone" $ do
       let manifest = mkManifest True [("README.md", "hello")]
           result = runPlan emptyFS manifest modName
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.files `shouldBe` [RemovalGone "README.md"]
+        Right plan -> (plan ^. #files) `shouldBe` [RemovalGone "README.md"]
 
     it "handles mix of safe, conflict, and gone files" $ do
       let manifest = mkManifest True [("a.txt", "aaa"), ("b.txt", "bbb"), ("c.txt", "ccc")]
@@ -165,10 +121,10 @@
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
         Right plan -> do
-          length plan.files `shouldBe` 3
-          RemovalSafe "a.txt" `elem` plan.files `shouldBe` True
-          RemovalConflict "b.txt" `elem` plan.files `shouldBe` True
-          RemovalGone "c.txt" `elem` plan.files `shouldBe` True
+          length (plan ^. #files) `shouldBe` 3
+          RemovalSafe "a.txt" `elem` (plan ^. #files) `shouldBe` True
+          RemovalConflict "b.txt" `elem` (plan ^. #files) `shouldBe` True
+          RemovalGone "c.txt" `elem` (plan ^. #files) `shouldBe` True
 
   describe "executeRemoval" $ do
     it "deletes safe files from the filesystem" $ do
@@ -176,7 +132,7 @@
           fs = mkFS [("README.md", "hello")]
           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "README.md"]}
           (_, finalFS) = runExec fs manifest plan Set.empty
-      Map.member "README.md" finalFS.files `shouldBe` False
+      Map.member "README.md" (finalFS ^. #files) `shouldBe` False
 
     it "preserves files in the keep-set" $ do
       let manifest = mkManifest True [("a.txt", "aaa")]
@@ -184,59 +140,59 @@
           plan = RemovalPlan {targetModule = modName, files = [RemovalConflict "a.txt"]}
           keepSet = Set.singleton "a.txt"
           (_, finalFS) = runExec fs manifest plan keepSet
-      Map.member "a.txt" finalFS.files `shouldBe` True
+      Map.member "a.txt" (finalFS ^. #files) `shouldBe` True
 
     it "removes the module from manifest.modules" $ do
       let manifest = mkManifest True [("README.md", "hello")]
           fs = mkFS [("README.md", "hello")]
           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "README.md"]}
           (updated, _) = runExec fs manifest plan Set.empty
-      updated.modules `shouldBe` []
+      (updated ^. #modules) `shouldBe` []
 
     it "removes module's files from manifest.files" $ do
       let manifest = mkManifest True [("a.txt", "aaa"), ("b.txt", "bbb")]
           fs = mkFS [("a.txt", "aaa"), ("b.txt", "bbb")]
           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "a.txt", RemovalSafe "b.txt"]}
           (updated, _) = runExec fs manifest plan Set.empty
-      Map.null updated.files `shouldBe` True
+      Map.null (updated ^. #files) `shouldBe` True
 
     it "preserves files from other modules in manifest" $ do
       let base = mkManifest True [("mine.txt", "mine")]
           otherRec = FileRecord (hashContent "other") otherMod Template fixedTime Nothing mempty
           manifest =
             Manifest
-              { version = base.version,
-                genAt = base.genAt,
-                modules = base.modules,
-                vars = base.vars,
-                files = Map.insert "other.txt" otherRec base.files,
-                applications = base.applications,
-                recipe = base.recipe,
-                blueprint = base.blueprint,
-                blueprintMigrations = base.blueprintMigrations
+              { version = base ^. #version,
+                genAt = base ^. #genAt,
+                modules = base ^. #modules,
+                vars = base ^. #vars,
+                files = Map.insert "other.txt" otherRec (base ^. #files),
+                applications = base ^. #applications,
+                recipe = base ^. #recipe,
+                blueprint = base ^. #blueprint,
+                blueprintMigrations = base ^. #blueprintMigrations
               }
           fs = mkFS [("mine.txt", "mine"), ("other.txt", "other")]
           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "mine.txt"]}
           (updated, _) = runExec fs manifest plan Set.empty
-      Map.member "other.txt" updated.files `shouldBe` True
-      Map.member "mine.txt" updated.files `shouldBe` False
+      Map.member "other.txt" (updated ^. #files) `shouldBe` True
+      Map.member "mine.txt" (updated ^. #files) `shouldBe` False
 
     it "updates genAt timestamp in manifest" $ do
       let manifest = mkManifest True [("a.txt", "aaa")]
           fs = mkFS [("a.txt", "aaa")]
           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "a.txt"]}
           (updated, _) = runExec fs manifest plan Set.empty
-      updated.genAt `shouldBe` removeTime
+      (updated ^. #genAt) `shouldBe` removeTime
 
     it "full round-trip: manifest returns to clean state after removal" $ do
       let manifest = mkManifest True [("a.txt", "aaa"), ("b.txt", "bbb")]
           fs = mkFS [("a.txt", "aaa"), ("b.txt", "bbb")]
           plan = RemovalPlan {targetModule = modName, files = [RemovalSafe "a.txt", RemovalSafe "b.txt"]}
           (updated, finalFS) = runExec fs manifest plan Set.empty
-      updated.modules `shouldBe` []
-      Map.null updated.files `shouldBe` True
-      Map.member "a.txt" finalFS.files `shouldBe` False
-      Map.member "b.txt" finalFS.files `shouldBe` False
+      (updated ^. #modules) `shouldBe` []
+      Map.null (updated ^. #files) `shouldBe` True
+      Map.member "a.txt" (finalFS ^. #files) `shouldBe` False
+      Map.member "b.txt" (finalFS ^. #files) `shouldBe` False
 
   describe "buildRemovalOps" $ do
     it "returns ModuleNotApplied when module is not in manifest" $ do
@@ -252,7 +208,7 @@
           result = runBuildOps fs manifest modName removal
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.ops `shouldBe` [DeleteFileOp "README.md" RFSafe]
+        Right plan -> (plan ^. #ops) `shouldBe` [DeleteFileOp "README.md" RFSafe]
 
     it "builds DeleteFileOp with RFConflict for modified files" $ do
       let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing] []
@@ -261,7 +217,7 @@
           result = runBuildOps fs manifest modName removal
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.ops `shouldBe` [DeleteFileOp "README.md" RFConflict]
+        Right plan -> (plan ^. #ops) `shouldBe` [DeleteFileOp "README.md" RFConflict]
 
     it "builds DeleteFileOp with RFGone for already-deleted files" $ do
       let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing] []
@@ -269,7 +225,7 @@
           result = runBuildOps emptyFS manifest modName removal
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.ops `shouldBe` [DeleteFileOp "README.md" RFGone]
+        Right plan -> (plan ^. #ops) `shouldBe` [DeleteFileOp "README.md" RFGone]
 
     it "builds StripSectionOp for remove-section steps" $ do
       let removal = Removal [RemovalStep RemoveSectionAction ".gitignore" Nothing] []
@@ -278,7 +234,7 @@
           result = runBuildOps fs manifest modName removal
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.ops `shouldBe` [StripSectionOp ".gitignore"]
+        Right plan -> (plan ^. #ops) `shouldBe` [StripSectionOp ".gitignore"]
 
     it "builds RemovalCommandOp for removal commands" $ do
       let removal = Removal [] [Command "cabal clean" Nothing Nothing]
@@ -286,7 +242,7 @@
           result = runBuildOps emptyFS manifest modName removal
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
-        Right plan -> plan.ops `shouldBe` [RemovalCommandOp "cabal clean" Nothing]
+        Right plan -> (plan ^. #ops) `shouldBe` [RemovalCommandOp "cabal clean" Nothing]
 
     it "rejects a removal step destination with a parent directory segment" $ do
       let removal = Removal [RemovalStep RemoveFileAction "../outside" Nothing] []
@@ -300,7 +256,7 @@
               "../outside"
               "path must not contain '..' segment: ../outside"
           )
-      Map.member "../outside" fs.files `shouldBe` True
+      Map.member "../outside" (fs ^. #files) `shouldBe` True
 
     it "rejects a removal command workDir with a parent directory segment" $ do
       let removal = Removal [] [Command "echo unsafe" (Just "../outside") Nothing]
@@ -325,8 +281,8 @@
       case result of
         Left err -> expectationFailure ("unexpected error: " <> show err)
         Right plan -> do
-          length plan.ops `shouldBe` 3
-          case plan.ops of
+          length (plan ^. #ops) `shouldBe` 3
+          case plan ^. #ops of
             [DeleteFileOp _ _, StripSectionOp _, RemovalCommandOp _ _] -> pure ()
             other -> expectationFailure ("unexpected ops: " <> show other)
 
@@ -337,7 +293,7 @@
           fs = mkFS [("README.md", "hello")]
           plan = ExecutedRemovalPlan modName [DeleteFileOp "README.md" RFSafe]
           (_, finalFS) = runExecOps fs manifest plan Set.empty
-      Map.member "README.md" finalFS.files `shouldBe` False
+      Map.member "README.md" (finalFS ^. #files) `shouldBe` False
 
     it "preserves files in keep-set for DeleteFileOp" $ do
       let removal = Removal [RemovalStep RemoveFileAction "a.txt" Nothing] []
@@ -346,13 +302,13 @@
           plan = ExecutedRemovalPlan modName [DeleteFileOp "a.txt" RFConflict]
           keepSet = Set.singleton "a.txt"
           (_, finalFS) = runExecOps fs manifest plan keepSet
-      Map.member "a.txt" finalFS.files `shouldBe` True
+      Map.member "a.txt" (finalFS ^. #files) `shouldBe` True
 
     it "skips gone files" $ do
       let manifest = mkManifest True [("a.txt", "aaa")]
           plan = ExecutedRemovalPlan modName [DeleteFileOp "a.txt" RFGone]
           (updated, _) = runExecOps emptyFS manifest plan Set.empty
-      updated.modules `shouldBe` []
+      (updated ^. #modules) `shouldBe` []
 
     it "strips section from file with StripSectionOp" $ do
       let content = "before\n# --- seihou:test-module ---\nmodule content\n# --- /seihou:test-module ---\nafter\n"
@@ -360,7 +316,7 @@
           fs = mkFS [(".gitignore", content)]
           plan = ExecutedRemovalPlan modName [StripSectionOp ".gitignore"]
           (_, finalFS) = runExecOps fs manifest plan Set.empty
-      case Map.lookup ".gitignore" finalFS.files of
+      case Map.lookup ".gitignore" (finalFS ^. #files) of
         Nothing -> expectationFailure ".gitignore should still exist"
         Just result -> do
           result `shouldSatisfy` \t ->
@@ -372,7 +328,7 @@
           fs = mkFS [("file.txt", content)]
           plan = ExecutedRemovalPlan modName [StripSectionOp "file.txt"]
           (_, finalFS) = runExecOps fs manifest plan Set.empty
-      Map.lookup "file.txt" finalFS.files `shouldBe` Just content
+      Map.lookup "file.txt" (finalFS ^. #files) `shouldBe` Just content
 
     it "removes module from manifest after all steps" $ do
       let removal = Removal [RemovalStep RemoveFileAction "a.txt" Nothing] []
@@ -380,26 +336,26 @@
           fs = mkFS [("a.txt", "aaa")]
           plan = ExecutedRemovalPlan modName [DeleteFileOp "a.txt" RFSafe]
           (updated, _) = runExecOps fs manifest plan Set.empty
-      updated.modules `shouldBe` []
-      Map.null updated.files `shouldBe` True
+      (updated ^. #modules) `shouldBe` []
+      Map.null (updated ^. #files) `shouldBe` True
 
     it "preserves other modules' files in manifest" $ do
       let base = mkManifest True [("mine.txt", "mine")]
           otherRec = FileRecord (hashContent "other") otherMod Template fixedTime Nothing mempty
           manifest =
             Manifest
-              { version = base.version,
-                genAt = base.genAt,
-                modules = base.modules,
-                vars = base.vars,
-                files = Map.insert "other.txt" otherRec base.files,
-                applications = base.applications,
-                recipe = base.recipe,
-                blueprint = base.blueprint,
-                blueprintMigrations = base.blueprintMigrations
+              { version = base ^. #version,
+                genAt = base ^. #genAt,
+                modules = base ^. #modules,
+                vars = base ^. #vars,
+                files = Map.insert "other.txt" otherRec (base ^. #files),
+                applications = base ^. #applications,
+                recipe = base ^. #recipe,
+                blueprint = base ^. #blueprint,
+                blueprintMigrations = base ^. #blueprintMigrations
               }
           fs = mkFS [("mine.txt", "mine"), ("other.txt", "other")]
           plan = ExecutedRemovalPlan modName [DeleteFileOp "mine.txt" RFSafe]
           (updated, _) = runExecOps fs manifest plan Set.empty
-      Map.member "other.txt" updated.files `shouldBe` True
-      Map.member "mine.txt" updated.files `shouldBe` False
+      Map.member "other.txt" (updated ^. #files) `shouldBe` True
+      Map.member "mine.txt" (updated ^. #files) `shouldBe` False
diff --git a/test/Seihou/Engine/SectionSpec.hs b/test/Seihou/Engine/SectionSpec.hs
--- a/test/Seihou/Engine/SectionSpec.hs
+++ b/test/Seihou/Engine/SectionSpec.hs
@@ -1,5 +1,6 @@
 module Seihou.Engine.SectionSpec (tests) where
 
+import Control.Lens ((&), (.~))
 import Data.Text qualified as T
 import Seihou.Core.Types
 import Seihou.Engine.Section
@@ -14,7 +15,7 @@
 modName = ModuleName "nix-flake"
 
 marker :: SectionMarker
-marker = SectionMarker {sectionPrefix = "#", sectionModule = modName}
+marker = SectionMarker {prefix = "#", module_ = modName}
 
 spec :: Spec
 spec = do
@@ -23,7 +24,7 @@
       renderSectionOpen marker `shouldBe` "# --- seihou:nix-flake ---\n"
 
     it "produces correct format with -- prefix" $ do
-      let hsMarker = marker {sectionPrefix = "--"}
+      let hsMarker = (marker & #prefix .~ "--")
       renderSectionOpen hsMarker `shouldBe` "-- --- seihou:nix-flake ---\n"
 
   describe "renderSectionClose" $ do
@@ -31,7 +32,7 @@
       renderSectionClose marker `shouldBe` "# --- /seihou:nix-flake ---\n"
 
     it "produces correct format with -- prefix" $ do
-      let hsMarker = marker {sectionPrefix = "--"}
+      let hsMarker = (marker & #prefix .~ "--")
       renderSectionClose hsMarker `shouldBe` "-- --- /seihou:nix-flake ---\n"
 
   describe "wrapInSection" $ do
diff --git a/test/Seihou/Engine/UpdateTransactionSpec.hs b/test/Seihou/Engine/UpdateTransactionSpec.hs
--- a/test/Seihou/Engine/UpdateTransactionSpec.hs
+++ b/test/Seihou/Engine/UpdateTransactionSpec.hs
@@ -1,9 +1,11 @@
 module Seihou.Engine.UpdateTransactionSpec (tests) where
 
 import Control.Exception (throwIO)
+import Control.Lens ((^.))
 import Control.Monad (unless, when)
 import Data.ByteString.Lazy qualified as LBS
 import Data.Foldable (traverse_)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -73,7 +75,7 @@
                       ],
                   requiredDirectories = Set.empty
                 }
-        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Map.keysSet plan.files)
+        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Map.keysSet (plan ^. #files))
         candidate <- expectRight =<< applyReconciliation transaction plan manifest
 
         readProject projectRoot "merged.txt" `shouldReturn` "user and generated\n"
@@ -81,19 +83,19 @@
         readProject projectRoot "edited.txt" `shouldReturn` "user orphan\n"
         readProject projectRoot "shared.txt" `shouldReturn` "shared\n"
 
-        let mergedRecord = candidate.files Map.! "merged.txt"
+        let mergedRecord = (candidate ^. #files) Map.! "merged.txt"
             baseline = baselineRefForContent "generated\n"
-        mergedRecord.hash `shouldBe` hashContent "user and generated\n"
-        mergedRecord.baseline `shouldBe` Just baseline
-        mergedRecord.applicationIds `shouldBe` Set.singleton appA
-        Map.member "safe.txt" candidate.files `shouldBe` False
-        candidate.files Map.! "edited.txt" `shouldBe` oldEdited
-        (candidate.files Map.! "shared.txt").applicationIds `shouldBe` Set.singleton appB
+        (mergedRecord ^. #hash) `shouldBe` hashContent "user and generated\n"
+        (mergedRecord ^. #baseline) `shouldBe` Just baseline
+        (mergedRecord ^. #applicationIds) `shouldBe` Set.singleton appA
+        Map.member "safe.txt" (candidate ^. #files) `shouldBe` False
+        (candidate ^. #files) Map.! "edited.txt" `shouldBe` oldEdited
+        (((candidate ^. #files) Map.! "shared.txt") ^. #applicationIds) `shouldBe` Set.singleton appB
         readProject projectRoot (".seihou/baselines" </> refName baseline) `shouldReturn` "generated\n"
 
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` True
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` True
         completeUpdateTransaction transaction `shouldReturn` Right ()
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "advances the baseline but preserves disk and applied hash for KeepCurrent" $
       withSystemTempDirectory "seihou-update-keep-current" $ \projectRoot -> do
@@ -115,9 +117,9 @@
         transaction <- expectRight =<< beginUpdateTransaction projectRoot (Set.singleton "file.txt")
         candidate <- expectRight =<< applyReconciliation transaction resolvedPlan manifest
         readProject projectRoot "file.txt" `shouldReturn` "user\n"
-        let resultRecord = candidate.files Map.! "file.txt"
-        resultRecord.hash `shouldBe` hashContent "user\n"
-        resultRecord.baseline `shouldBe` Just (baselineRefForContent "generated\n")
+        let resultRecord = (candidate ^. #files) Map.! "file.txt"
+        (resultRecord ^. #hash) `shouldBe` hashContent "user\n"
+        (resultRecord ^. #baseline) `shouldBe` Just (baselineRefForContent "generated\n")
         completeUpdateTransaction transaction `shouldReturn` Right ()
 
     it "rejects a stale plan before its first mutation" $
@@ -133,7 +135,7 @@
         result <- applyReconciliation transaction plan (manifestWithFiles Map.empty)
         result `shouldSatisfy` isStale
         readProject projectRoot "file.txt" `shouldReturn` "planned\n"
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "deletes or detaches edited orphans only after explicit resolution" $
       withSystemTempDirectory "seihou-update-orphan-resolution" $ \projectRoot -> do
@@ -165,11 +167,11 @@
                 )
                 Set.empty
             manifest = manifestWithFiles (Map.fromList [("delete.txt", deleteRecord), ("detach.txt", detachRecord)])
-        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Map.keysSet plan.files)
+        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Map.keysSet (plan ^. #files))
         candidate <- expectRight =<< applyReconciliation transaction plan manifest
         Directory.doesFileExist (projectRoot </> "delete.txt") `shouldReturn` False
         readProject projectRoot "detach.txt" `shouldReturn` "user detach\n"
-        candidate.files `shouldBe` Map.empty
+        (candidate ^. #files) `shouldBe` Map.empty
         completeUpdateTransaction transaction `shouldReturn` Right ()
 
     it "refuses unresolved plans without touching disk" $
@@ -183,7 +185,7 @@
         result <- applyReconciliation transaction plan (manifestWithFiles Map.empty)
         result `shouldSatisfy` isUnresolved
         readProject projectRoot "file.txt" `shouldReturn` "user\n"
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
   describe "rollback and recovery" $ do
     it "rolls every earlier mutation back after an injected failure" $
@@ -201,7 +203,7 @@
                     ]
                 )
                 Set.empty
-        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Map.keysSet plan.files)
+        transaction <- expectRight =<< beginUpdateTransaction projectRoot (Map.keysSet (plan ^. #files))
         result <-
           applyReconciliationWithHook
             (\count -> when (count == 1) (throwIO (userError "injected failure")))
@@ -211,7 +213,7 @@
         result `shouldSatisfy` isApplyFailure
         readProject projectRoot "one.txt" `shouldReturn` "old one\n"
         readProject projectRoot "two.txt" `shouldReturn` "old two\n"
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "restores a well-formed leftover journal on startup" $
       withSystemTempDirectory "seihou-update-recover" $ \projectRoot -> do
@@ -220,7 +222,7 @@
         writeProject projectRoot "file.txt" "interrupted\n"
         recoverIncompleteTransactions projectRoot `shouldReturn` [Right ()]
         readProject projectRoot "file.txt" `shouldReturn` "old\n"
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "recovers an applied but unpublished candidate and removes its new empty directories" $
       withSystemTempDirectory "seihou-update-unpublished" $ \projectRoot -> do
@@ -238,7 +240,7 @@
         recoverIncompleteTransactions projectRoot `shouldReturn` [Right ()]
         readProject projectRoot "file.txt" `shouldReturn` "old\n"
         Directory.doesDirectoryExist (projectRoot </> "empty") `shouldReturn` False
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "keeps committed files when the durable manifest matches the journal" $
       withSystemTempDirectory "seihou-update-committed" $ \projectRoot -> do
@@ -255,7 +257,7 @@
         LBS.writeFile (projectRoot </> ".seihou" </> "manifest.json") (manifestToJSON candidate)
         recoverIncompleteTransactions projectRoot `shouldReturn` [Right ()]
         readProject projectRoot "file.txt" `shouldReturn` "new\n"
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "uses an orchestrator's complete final manifest as the recovery commit marker" $
       withSystemTempDirectory "seihou-update-final-marker" $ \projectRoot -> do
@@ -271,22 +273,22 @@
         let finalManifest :: Manifest
             finalManifest =
               Manifest
-                { version = candidate.version,
-                  genAt = candidate.genAt,
-                  modules = candidate.modules,
+                { version = candidate ^. #version,
+                  genAt = candidate ^. #genAt,
+                  modules = candidate ^. #modules,
                   vars = Map.singleton "published" "yes",
-                  files = candidate.files,
-                  applications = candidate.applications,
-                  recipe = candidate.recipe,
-                  blueprint = candidate.blueprint,
-                  blueprintMigrations = candidate.blueprintMigrations
+                  files = candidate ^. #files,
+                  applications = candidate ^. #applications,
+                  recipe = candidate ^. #recipe,
+                  blueprint = candidate ^. #blueprint,
+                  blueprintMigrations = candidate ^. #blueprintMigrations
                 }
         setUpdateTransactionExpectedManifest transaction finalManifest `shouldReturn` Right ()
         Directory.createDirectoryIfMissing True (projectRoot </> ".seihou")
         LBS.writeFile (projectRoot </> ".seihou" </> "manifest.json") (manifestToJSON finalManifest)
         recoverIncompleteTransactions projectRoot `shouldReturn` [Right ()]
         readProject projectRoot "file.txt" `shouldReturn` "new\n"
-        Directory.doesDirectoryExist transaction.transactionDirectory `shouldReturn` False
+        Directory.doesDirectoryExist (transaction ^. #transactionDirectory) `shouldReturn` False
 
     it "quarantines malformed journal metadata instead of deleting it" $
       withSystemTempDirectory "seihou-update-malformed" $ \projectRoot -> do
@@ -352,8 +354,8 @@
           merged `shouldSatisfy` T.isInfixOf "module"
           Directory.doesFileExist (projectRoot </> "safe.txt") `shouldReturn` False
           readProject projectRoot "edited.txt" `shouldReturn` "user orphan\n"
-          Map.member "safe.txt" candidate.files `shouldBe` False
-          Map.member "edited.txt" candidate.files `shouldBe` True
+          Map.member "safe.txt" (candidate ^. #files) `shouldBe` False
+          Map.member "edited.txt" (candidate ^. #files) `shouldBe` True
           Directory.listDirectory (projectRoot </> ".seihou" </> "transactions") `shouldReturn` []
 
 fixedTime :: UTCTime
@@ -367,15 +369,15 @@
 manifestWithFiles fileRecords =
   let manifest = emptyManifest fixedTime
    in Manifest
-        { version = manifest.version,
-          genAt = manifest.genAt,
-          modules = manifest.modules,
-          vars = manifest.vars,
+        { version = manifest ^. #version,
+          genAt = manifest ^. #genAt,
+          modules = manifest ^. #modules,
+          vars = manifest ^. #vars,
           files = fileRecords,
-          applications = manifest.applications,
-          recipe = manifest.recipe,
-          blueprint = manifest.blueprint,
-          blueprintMigrations = manifest.blueprintMigrations
+          applications = manifest ^. #applications,
+          recipe = manifest ^. #recipe,
+          blueprint = manifest ^. #blueprint,
+          blueprintMigrations = manifest ^. #blueprintMigrations
         }
 
 fileRecord :: Text -> Maybe BaselineRef -> [ApplicationId] -> FileRecord
@@ -420,7 +422,7 @@
 readProject projectRoot relativePath = TIO.readFile (projectRoot </> relativePath)
 
 refName :: BaselineRef -> FilePath
-refName reference = T.unpack reference.unBaselineRef.unSHA256
+refName reference = T.unpack (reference ^. #unBaselineRef . #unSHA256)
 
 expectRight :: (Show error) => Either error value -> IO value
 expectRight (Right value) = pure value
diff --git a/test/Seihou/Engine/ValidateSpec.hs b/test/Seihou/Engine/ValidateSpec.hs
--- a/test/Seihou/Engine/ValidateSpec.hs
+++ b/test/Seihou/Engine/ValidateSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Engine.ValidateSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Text qualified as T
 import Seihou.Core.Types
 import Seihou.Engine.Validate
@@ -69,31 +71,31 @@
 
 -- | Helper to update Module fields without ambiguity.
 withVars :: [VarDecl] -> Module -> Module
-withVars v m = Module m.name m.version m.description v m.exports m.prompts m.steps m.commands m.dependencies m.removal m.migrations
+withVars v m = Module (m ^. #name) (m ^. #version) (m ^. #description) v (m ^. #exports) (m ^. #prompts) (m ^. #steps) (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 withSteps :: [Step] -> Module -> Module
-withSteps s m = Module m.name m.version m.description m.vars m.exports m.prompts s m.commands m.dependencies m.removal m.migrations
+withSteps s m = Module (m ^. #name) (m ^. #version) (m ^. #description) (m ^. #vars) (m ^. #exports) (m ^. #prompts) s (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 withPrompts :: [Prompt] -> Module -> Module
-withPrompts p m = Module m.name m.version m.description m.vars m.exports p m.steps m.commands m.dependencies m.removal m.migrations
+withPrompts p m = Module (m ^. #name) (m ^. #version) (m ^. #description) (m ^. #vars) (m ^. #exports) p (m ^. #steps) (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 withCommands :: [Command] -> Module -> Module
-withCommands c m = Module m.name m.version m.description m.vars m.exports m.prompts m.steps c m.dependencies m.removal m.migrations
+withCommands c m = Module (m ^. #name) (m ^. #version) (m ^. #description) (m ^. #vars) (m ^. #exports) (m ^. #prompts) (m ^. #steps) c (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 withVarsAndPrompts :: [VarDecl] -> [Prompt] -> Module -> Module
-withVarsAndPrompts v p m = Module m.name m.version m.description v m.exports p m.steps m.commands m.dependencies m.removal m.migrations
+withVarsAndPrompts v p m = Module (m ^. #name) (m ^. #version) (m ^. #description) v (m ^. #exports) p (m ^. #steps) (m ^. #commands) (m ^. #dependencies) (m ^. #removal) (m ^. #migrations)
 
 -- | Helper: check if any DiagCheck has the given label and non-empty details.
 hasFailedCheck :: T.Text -> [DiagCheck] -> Bool
-hasFailedCheck label = any (\c -> c.diagLabel == label && not (null (c.diagDetails)))
+hasFailedCheck label = any (\c -> c ^. #label == label && not (null (c ^. #details)))
 
 -- | Helper: check if any DiagCheck has the given label and empty details (pass).
 hasPassedCheck :: T.Text -> [DiagCheck] -> Bool
-hasPassedCheck label = any (\c -> c.diagLabel == label && null (c.diagDetails))
+hasPassedCheck label = any (\c -> c ^. #label == label && null (c ^. #details))
 
 -- | Helper: count checks with non-empty details of a given severity.
 countFailures :: DiagSeverity -> [DiagCheck] -> Int
-countFailures sev = length . filter (\c -> c.diagSeverity == sev && not (null (c.diagDetails)))
+countFailures sev = length . filter (\c -> c ^. #severity == sev && not (null (c ^. #details)))
 
 spec :: Spec
 spec = do
@@ -103,64 +105,64 @@
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport False tmpDir goodModule
-        report.reportDhallOk `shouldBe` True
+        (report ^. #dhallOk) `shouldBe` True
         reportHasErrors report `shouldBe` False
-        countFailures DiagError report.reportChecks `shouldBe` 0
+        countFailures DiagError (report ^. #checks) `shouldBe` 0
 
     it "detects module name format errors" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Module name format" report.reportChecks `shouldBe` True
+        hasFailedCheck "Module name format" (report ^. #checks) `shouldBe` True
 
     it "detects duplicate variable names" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Unique variable names" report.reportChecks `shouldBe` True
+        hasFailedCheck "Unique variable names" (report ^. #checks) `shouldBe` True
 
     it "detects export referencing undeclared variable" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Export references" report.reportChecks `shouldBe` True
+        hasFailedCheck "Export references" (report ^. #checks) `shouldBe` True
 
     it "detects prompt referencing undeclared variable" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Prompt references" report.reportChecks `shouldBe` True
+        hasFailedCheck "Prompt references" (report ^. #checks) `shouldBe` True
 
     it "detects missing source files" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Source file existence" report.reportChecks `shouldBe` True
+        hasFailedCheck "Source file existence" (report ^. #checks) `shouldBe` True
 
     it "detects unsafe step destinations" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Safe step destinations" report.reportChecks `shouldBe` True
+        hasFailedCheck "Safe step destinations" (report ^. #checks) `shouldBe` True
 
     it "detects missing module version" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
-        hasFailedCheck "Module version declared" report.reportChecks `shouldBe` True
+        hasFailedCheck "Module version declared" (report ^. #checks) `shouldBe` True
 
     it "passes when module has a version" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport False tmpDir goodModule
-        hasPassedCheck "Module version declared" report.reportChecks `shouldBe` True
+        hasPassedCheck "Module version declared" (report ^. #checks) `shouldBe` True
 
     it "reports multiple errors at once" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         report <- buildReport False tmpDir badModule
         reportHasErrors report `shouldBe` True
-        countFailures DiagError report.reportChecks `shouldSatisfy` (>= 5)
+        countFailures DiagError (report ^. #checks) `shouldSatisfy` (>= 5)
 
     it "does not include lint checks when lint is False" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport False tmpDir goodModule
-        let hasWarning = any (\c -> c.diagSeverity == DiagWarning) report.reportChecks
+        let hasWarning = any (\c -> c ^. #severity == DiagWarning) (report ^. #checks)
         hasWarning `shouldBe` False
 
     it "includes lint checks when lint is True" $ do
@@ -168,7 +170,7 @@
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport True tmpDir goodModule
-        let hasWarning = any (\c -> c.diagSeverity == DiagWarning) report.reportChecks
+        let hasWarning = any (\c -> c ^. #severity == DiagWarning) (report ^. #checks)
         hasWarning `shouldBe` True
 
   describe "lint checks" $ do
@@ -183,8 +185,8 @@
                 ]
                 goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Unused variables" report.reportChecks `shouldBe` True
-        let details = concatMap (.diagDetails) $ filter (\c -> c.diagLabel == "Unused variables") report.reportChecks
+        hasFailedCheck "Unused variables" (report ^. #checks) `shouldBe` True
+        let details = concatMap (^. #details) $ filter (\c -> c ^. #label == "Unused variables") (report ^. #checks)
         any (T.isInfixOf "unused.var") details `shouldBe` True
 
     it "does not flag used variables as unused" $ do
@@ -192,7 +194,7 @@
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport True tmpDir goodModule
-        hasFailedCheck "Unused variables" report.reportChecks `shouldBe` False
+        hasFailedCheck "Unused variables" (report ^. #checks) `shouldBe` False
 
     it "detects required variables without prompts" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -204,14 +206,14 @@
                 []
                 goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Required variables without prompts" report.reportChecks `shouldBe` True
+        hasFailedCheck "Required variables without prompts" (report ^. #checks) `shouldBe` True
 
     it "does not flag required variables that have prompts" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport True tmpDir goodModule
-        hasFailedCheck "Required variables without prompts" report.reportChecks `shouldBe` False
+        hasFailedCheck "Required variables without prompts" (report ^. #checks) `shouldBe` False
 
     it "detects duplicate step destinations" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -225,7 +227,7 @@
                 ]
                 goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Duplicate step destinations" report.reportChecks `shouldBe` True
+        hasFailedCheck "Duplicate step destinations" (report ^. #checks) `shouldBe` True
 
     it "does not flag patch ops as duplicate destinations" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -239,7 +241,7 @@
                 ]
                 goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Duplicate step destinations" report.reportChecks `shouldBe` False
+        hasFailedCheck "Duplicate step destinations" (report ^. #checks) `shouldBe` False
 
     it "detects empty choice lists" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -250,7 +252,7 @@
                 [VarDecl "pick" (VTChoice []) Nothing (Just "Pick") True Nothing]
                 goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Empty choice lists" report.reportChecks `shouldBe` True
+        hasFailedCheck "Empty choice lists" (report ^. #checks) `shouldBe` True
 
     it "detects missing variable descriptions" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -261,14 +263,14 @@
                 [VarDecl "project.name" VTText Nothing Nothing True Nothing]
                 goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Missing variable descriptions" report.reportChecks `shouldBe` True
+        hasFailedCheck "Missing variable descriptions" (report ^. #checks) `shouldBe` True
 
     it "does not flag variables with descriptions" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
         createDirectoryIfMissing True (tmpDir </> "files")
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         report <- buildReport True tmpDir goodModule
-        hasFailedCheck "Missing variable descriptions" report.reportChecks `shouldBe` False
+        hasFailedCheck "Missing variable descriptions" (report ^. #checks) `shouldBe` False
 
   describe "conditional lint" $ do
     -- Base vars: keep project.name (referenced by goodModule's export/prompt)
@@ -288,11 +290,11 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = stepWithCondition (Just (ExprEq "feature.on" (VText "true")))
         report <- buildReport True tmpDir m
-        hasFailedCheck "Conditional comparison types" report.reportChecks `shouldBe` True
+        hasFailedCheck "Conditional comparison types" (report ^. #checks) `shouldBe` True
         reportHasErrors report `shouldBe` True
         let details =
-              concatMap (.diagDetails) $
-                filter (\c -> c.diagLabel == "Conditional comparison types") report.reportChecks
+              concatMap (^. #details) $
+                filter (\c -> c ^. #label == "Conditional comparison types") (report ^. #checks)
         any (T.isInfixOf "feature.on") details `shouldBe` True
         any (T.isInfixOf "bareword true") details `shouldBe` True
 
@@ -302,8 +304,8 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = stepWithCondition (Just (ExprEq "feature.on" (VBool True)))
         report <- buildReport True tmpDir m
-        hasFailedCheck "Conditional comparison types" report.reportChecks `shouldBe` False
-        hasPassedCheck "Conditional comparison types" report.reportChecks `shouldBe` True
+        hasFailedCheck "Conditional comparison types" (report ^. #checks) `shouldBe` False
+        hasPassedCheck "Conditional comparison types" (report ^. #checks) `shouldBe` True
 
     it "flags a when clause referencing an undeclared variable" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -311,10 +313,10 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = stepWithCondition (Just (ExprIsSet "nix.treefmtt"))
         report <- buildReport True tmpDir m
-        hasFailedCheck "Conditional variable references" report.reportChecks `shouldBe` True
+        hasFailedCheck "Conditional variable references" (report ^. #checks) `shouldBe` True
         let details =
-              concatMap (.diagDetails) $
-                filter (\c -> c.diagLabel == "Conditional variable references") report.reportChecks
+              concatMap (^. #details) $
+                filter (\c -> c ^. #label == "Conditional variable references") (report ^. #checks)
         any (T.isInfixOf "nix.treefmtt") details `shouldBe` True
 
     it "passes both conditional checks for a correct module" $ do
@@ -323,8 +325,8 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = stepWithCondition (Just (ExprEq "feature.on" (VBool True)))
         report <- buildReport True tmpDir m
-        hasPassedCheck "Conditional variable references" report.reportChecks `shouldBe` True
-        hasPassedCheck "Conditional comparison types" report.reportChecks `shouldBe` True
+        hasPassedCheck "Conditional variable references" (report ^. #checks) `shouldBe` True
+        hasPassedCheck "Conditional comparison types" (report ^. #checks) `shouldBe` True
 
     it "does not run conditional checks when lint is False" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -332,7 +334,7 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = stepWithCondition (Just (ExprEq "feature.on" (VText "true")))
         report <- buildReport False tmpDir m
-        any (\c -> c.diagLabel == "Conditional comparison types") report.reportChecks
+        any (\c -> c ^. #label == "Conditional comparison types") (report ^. #checks)
           `shouldBe` False
         reportHasErrors report `shouldBe` False
 
@@ -344,10 +346,10 @@
           "{{#if Eq ghost true}}\nhi\n{{/if}}\n"
         let m = withVars baseVars goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Conditional variable references" report.reportChecks `shouldBe` True
+        hasFailedCheck "Conditional variable references" (report ^. #checks) `shouldBe` True
         let details =
-              concatMap (.diagDetails) $
-                filter (\c -> c.diagLabel == "Conditional variable references") report.reportChecks
+              concatMap (^. #details) $
+                filter (\c -> c ^. #label == "Conditional variable references") (report ^. #checks)
         any (T.isInfixOf "ghost") details `shouldBe` True
         any (T.isInfixOf "README.md.tpl") details `shouldBe` True
 
@@ -359,7 +361,7 @@
           "{{#if Eq feature.on \"true\"}}\nhi\n{{/if}}\n"
         let m = withVars baseVars goodModule
         report <- buildReport True tmpDir m
-        hasFailedCheck "Conditional comparison types" report.reportChecks `shouldBe` True
+        hasFailedCheck "Conditional comparison types" (report ^. #checks) `shouldBe` True
 
   describe "renderReportPlain" $ do
     it "renders a valid module report with check marks" $ do
@@ -382,11 +384,11 @@
     it "renders a Dhall-failure report" $ do
       let report =
             ValidateReport
-              { reportModule = goodModule,
-                reportPath = "/some/path",
-                reportDhallOk = False,
-                reportDhallError = Just "test error message",
-                reportChecks = []
+              { module_ = goodModule,
+                path = "/some/path",
+                dhallOk = False,
+                dhallError = Just "test error message",
+                checks = []
               }
           rendered = renderReportPlain report
       T.isInfixOf "\x2717 module.dhall failed to evaluate" rendered `shouldBe` True
@@ -396,11 +398,11 @@
     it "renders a Dhall-failure report without error details when absent" $ do
       let report =
             ValidateReport
-              { reportModule = goodModule,
-                reportPath = "/some/path",
-                reportDhallOk = False,
-                reportDhallError = Nothing,
-                reportChecks = []
+              { module_ = goodModule,
+                path = "/some/path",
+                dhallOk = False,
+                dhallError = Nothing,
+                checks = []
               }
           rendered = renderReportPlain report
       T.isInfixOf "\x2717 module.dhall failed to evaluate" rendered `shouldBe` True
@@ -449,11 +451,11 @@
     it "returns True when Dhall failed" $ do
       let report =
             ValidateReport
-              { reportModule = goodModule,
-                reportPath = "/some/path",
-                reportDhallOk = False,
-                reportDhallError = Just "some dhall error",
-                reportChecks = []
+              { module_ = goodModule,
+                path = "/some/path",
+                dhallOk = False,
+                dhallError = Just "some dhall error",
+                checks = []
               }
       reportHasErrors report `shouldBe` True
 
@@ -464,7 +466,7 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = withCommands [Command "echo hello" Nothing Nothing] goodModule
         report <- buildReport False tmpDir m
-        hasPassedCheck "Command safety" report.reportChecks `shouldBe` True
+        hasPassedCheck "Command safety" (report ^. #checks) `shouldBe` True
 
     it "fails for empty command text" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -472,7 +474,7 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = withCommands [Command "  " Nothing Nothing] goodModule
         report <- buildReport False tmpDir m
-        hasFailedCheck "Command safety" report.reportChecks `shouldBe` True
+        hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True
 
     it "fails for absolute workDir" $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -480,7 +482,7 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = withCommands [Command "echo hi" (Just "/usr/local") Nothing] goodModule
         report <- buildReport False tmpDir m
-        hasFailedCheck "Command safety" report.reportChecks `shouldBe` True
+        hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True
 
     it "fails for workDir containing .." $ do
       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do
@@ -488,4 +490,4 @@
         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"
         let m = withCommands [Command "echo hi" (Just "../escape") Nothing] goodModule
         report <- buildReport False tmpDir m
-        hasFailedCheck "Command safety" report.reportChecks `shouldBe` True
+        hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True
diff --git a/test/Seihou/Evaluation/ConditionalTemplateSpec.hs b/test/Seihou/Evaluation/ConditionalTemplateSpec.hs
--- a/test/Seihou/Evaluation/ConditionalTemplateSpec.hs
+++ b/test/Seihou/Evaluation/ConditionalTemplateSpec.hs
@@ -1,5 +1,6 @@
 module Seihou.Evaluation.ConditionalTemplateSpec (tests) where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
 import Data.Text.IO qualified as TIO
@@ -59,12 +60,12 @@
       case planResult of
         Left errs -> expectationFailure ("compilePlan failed: " <> show errs)
         Right ops -> do
-          let writeOps = [op | op@WriteFileOp {} <- ops]
+          let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
           writeOps `shouldSatisfy` (\xs -> length xs == 1)
-          let op = writeOps !! 0
-          op.dest `shouldBe` "flake.nix"
+          let (dest, content) = writeOps !! 0
+          dest `shouldBe` "flake.nix"
           expected <- splitFlakeBaseline "flake.nix.tpl" vars
-          op.content `shouldBe` expected
+          content `shouldBe` expected
 
     it "with nix.postgresql = true, emits bytes identical to the postgres split-flake baseline" $ do
       base <- fixtureDir
@@ -74,9 +75,9 @@
       case planResult of
         Left errs -> expectationFailure ("compilePlan failed: " <> show errs)
         Right ops -> do
-          let writeOps = [op | op@WriteFileOp {} <- ops]
+          let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
           writeOps `shouldSatisfy` (\xs -> length xs == 1)
-          let op = writeOps !! 0
-          op.dest `shouldBe` "flake.nix"
+          let (dest, content) = writeOps !! 0
+          dest `shouldBe` "flake.nix"
           expected <- splitFlakeBaseline "flake-with-postgres.nix.tpl" vars
-          op.content `shouldBe` expected
+          content `shouldBe` expected
diff --git a/test/Seihou/Evaluation/DhallTextFlakeSpec.hs b/test/Seihou/Evaluation/DhallTextFlakeSpec.hs
--- a/test/Seihou/Evaluation/DhallTextFlakeSpec.hs
+++ b/test/Seihou/Evaluation/DhallTextFlakeSpec.hs
@@ -1,5 +1,6 @@
 module Seihou.Evaluation.DhallTextFlakeSpec (tests) where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
 import Data.Text.IO qualified as TIO
@@ -56,12 +57,12 @@
       case planResult of
         Left errs -> expectationFailure ("compilePlan failed: " <> show errs)
         Right ops -> do
-          let writeOps = [op | op@WriteFileOp {} <- ops]
+          let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
           writeOps `shouldSatisfy` (\xs -> length xs == 1)
-          let op = writeOps !! 0
-          op.dest `shouldBe` "flake.nix"
+          let (dest, content) = writeOps !! 0
+          dest `shouldBe` "flake.nix"
           expected <- renderSplitFlake "flake.nix.tpl" vars
-          op.content `shouldBe` expected
+          content `shouldBe` expected
 
     it "with nix.postgresql = true, produces bytes identical to the postgres baseline" $ do
       base <- fixtureDir
@@ -71,9 +72,9 @@
       case planResult of
         Left errs -> expectationFailure ("compilePlan failed: " <> show errs)
         Right ops -> do
-          let writeOps = [op | op@WriteFileOp {} <- ops]
+          let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
           writeOps `shouldSatisfy` (\xs -> length xs == 1)
-          let op = writeOps !! 0
-          op.dest `shouldBe` "flake.nix"
+          let (dest, content) = writeOps !! 0
+          dest `shouldBe` "flake.nix"
           expected <- renderSplitFlake "flake-with-postgres.nix.tpl" vars
-          op.content `shouldBe` expected
+          content `shouldBe` expected
diff --git a/test/Seihou/Evaluation/SplitFlakeSpec.hs b/test/Seihou/Evaluation/SplitFlakeSpec.hs
--- a/test/Seihou/Evaluation/SplitFlakeSpec.hs
+++ b/test/Seihou/Evaluation/SplitFlakeSpec.hs
@@ -1,5 +1,6 @@
 module Seihou.Evaluation.SplitFlakeSpec (tests) where
 
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
 import Data.Text.IO qualified as TIO
@@ -50,12 +51,12 @@
       case planResult of
         Left errs -> expectationFailure ("compilePlan failed: " <> show errs)
         Right ops -> do
-          let writeOps = [op | op@WriteFileOp {} <- ops]
+          let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
           writeOps `shouldSatisfy` (\xs -> length xs == 1)
-          let op = writeOps !! 0
-          op.dest `shouldBe` "flake.nix"
+          let (dest, content) = writeOps !! 0
+          dest `shouldBe` "flake.nix"
           expected <- renderFixtureFile "flake.nix.tpl" vars
-          op.content `shouldBe` expected
+          content `shouldBe` expected
 
     it "with nix.postgresql = true, emits the postgres flake verbatim" $ do
       base <- fixtureDir
@@ -65,9 +66,9 @@
       case planResult of
         Left errs -> expectationFailure ("compilePlan failed: " <> show errs)
         Right ops -> do
-          let writeOps = [op | op@WriteFileOp {} <- ops]
+          let writeOps = [(dest, content) | WriteFileOp {dest, content} <- ops]
           writeOps `shouldSatisfy` (\xs -> length xs == 1)
-          let op = writeOps !! 0
-          op.dest `shouldBe` "flake.nix"
+          let (dest, content) = writeOps !! 0
+          dest `shouldBe` "flake.nix"
           expected <- renderFixtureFile "flake-with-postgres.nix.tpl" vars
-          op.content `shouldBe` expected
+          content `shouldBe` expected
diff --git a/test/Seihou/Integration/CompositionSpec.hs b/test/Seihou/Integration/CompositionSpec.hs
--- a/test/Seihou/Integration/CompositionSpec.hs
+++ b/test/Seihou/Integration/CompositionSpec.hs
@@ -1,6 +1,8 @@
 module Seihou.Integration.CompositionSpec (tests) where
 
+import Control.Lens ((^.))
 import Data.Either (isLeft)
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
 import Data.Text qualified as T
@@ -28,7 +30,7 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right modules -> do
-          let names = map (\(_, m, _) -> m.name) modules
+          let names = map (\(_, m, _) -> m ^. #name) modules
           length names `shouldBe` 4
           -- All four modules should be present
           elem "nix-base" names `shouldBe` True
@@ -41,7 +43,7 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right modules -> do
-          let names = map (\(_, m, _) -> m.name) modules
+          let names = map (\(_, m, _) -> m ^. #name) modules
               indexOf n = case lookup n (zip names [0 :: Int ..]) of
                 Just i -> i
                 Nothing -> error $ "Module not found: " ++ show n
@@ -61,7 +63,7 @@
         Right modules -> do
           length modules `shouldBe` 1
           case modules of
-            [(_, m, _)] -> m.name `shouldBe` "nix-base"
+            [(_, m, _)] -> (m ^. #name) `shouldBe` "nix-base"
             _ -> expectationFailure "Expected exactly one module"
 
     it "handles additional modules via --module flag" $ do
@@ -69,7 +71,7 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right modules -> do
-          let names = map (\(_, m, _) -> m.name) modules
+          let names = map (\(_, m, _) -> m ^. #name) modules
           length names `shouldBe` 2
           elem "haskell-base" names `shouldBe` True
           elem "nix-base" names `shouldBe` True
@@ -89,9 +91,9 @@
             Right resolved -> do
               let flakeVars = resolved Map.! primaryInstance "nix-flake"
               -- nix-flake should see nix.system from nix-base's export
-              (.value) (flakeVars Map.! "nix.system") `shouldBe` VText "x86_64-linux"
+              (^. #value) (flakeVars Map.! "nix.system") `shouldBe` VText "x86_64-linux"
               -- nix-flake should also have its own variable
-              (.value) (flakeVars Map.! "nix.description") `shouldBe` VText "A Nix project"
+              (^. #value) (flakeVars Map.! "nix.description") `shouldBe` VText "A Nix project"
 
     it "flows exports through diamond dependency" $ do
       result <- loadComposition [fixtureDir] "haskell-with-nix" []
@@ -104,11 +106,11 @@
             Right resolved -> do
               -- haskell-base should have project.name from CLI
               let baseVars = resolved Map.! primaryInstance "haskell-base"
-              (.value) (baseVars Map.! "project.name") `shouldBe` VText "my-app"
+              (^. #value) (baseVars Map.! "project.name") `shouldBe` VText "my-app"
               -- haskell-with-nix should inherit project.name via haskell-base's export
               let topVars = resolved Map.! primaryInstance "haskell-with-nix"
               Map.member "project.name" topVars `shouldBe` True
-              (.value) (topVars Map.! "project.name") `shouldBe` VText "my-app"
+              (^. #value) (topVars Map.! "project.name") `shouldBe` VText "my-app"
 
   describe "compileComposedPlan" $ do
     it "produces operations from all composed modules" $ do
@@ -121,7 +123,7 @@
             Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
             Right resolved -> do
               let quads =
-                    [ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
+                    [ (inst, m, dir, Map.map (^. #value) (resolved Map.! inst))
                     | (inst, m, dir) <- modules
                     ]
               planResult <- compileComposedPlan quads
@@ -147,7 +149,7 @@
             Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
             Right resolved -> do
               let quads =
-                    [ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
+                    [ (inst, m, dir, Map.map (^. #value) (resolved Map.! inst))
                     | (inst, m, dir) <- modules
                     ]
               planResult <- compileComposedPlan quads
@@ -176,7 +178,7 @@
             Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
             Right resolved -> do
               let quads =
-                    [ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
+                    [ (inst, m, dir, Map.map (^. #value) (resolved Map.! inst))
                     | (inst, m, dir) <- modules
                     ]
               planResult <- compileComposedPlan quads
@@ -212,7 +214,7 @@
           let helperInstances =
                 [ inst
                 | (inst, m, _) <- modules,
-                  m.name == "multi-instance-helper"
+                  m ^. #name == "multi-instance-helper"
                 ]
           length helperInstances `shouldBe` 2
           let bindings =
@@ -223,7 +225,7 @@
               haveSkill vn =
                 any
                   ( \inst ->
-                      Map.lookup "skill.name" inst.instanceParentVars.unParentVars == Just vn
+                      Map.lookup "skill.name" (inst ^. #parentVars . #unParentVars) == Just vn
                   )
                   helperInstances
           all haveSkill (Map.keys bindings) `shouldBe` True
@@ -237,7 +239,7 @@
             Left errs -> expectationFailure $ "Resolve failed: " ++ show errs
             Right resolved -> do
               let quads =
-                    [ (inst, m, dir, Map.map (.value) (resolved Map.! inst))
+                    [ (inst, m, dir, Map.map (^. #value) (resolved Map.! inst))
                     | (inst, m, dir) <- modules
                     ]
               planResult <- compileComposedPlan quads
@@ -267,7 +269,7 @@
           a = mkMod "a" ["b"]
           b = mkMod "b" ["c"]
           c = mkMod "c" ["a"]
-          graph = buildGraph [(primaryInstance m.name, m) | m <- [a, b, c]]
+          graph = buildGraph [(primaryInstance (m ^. #name), m) | m <- [a, b, c]]
       topoSort graph `shouldSatisfy` isLeft
 
 isContentMerged :: CompositionWarning -> Bool
diff --git a/test/Seihou/Integration/ExecutionSpec.hs b/test/Seihou/Integration/ExecutionSpec.hs
--- a/test/Seihou/Integration/ExecutionSpec.hs
+++ b/test/Seihou/Integration/ExecutionSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Integration.ExecutionSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text (Text)
@@ -33,7 +35,7 @@
 
 -- | Helper to extract the resolved variable values map.
 resolvedValues :: Map.Map VarName ResolvedVar -> Map.Map VarName VarValue
-resolvedValues = Map.map (.value)
+resolvedValues = Map.map (^. #value)
 
 -- | Load haskell-base fixture, resolve vars, compile plan.
 compileFixturePlan :: [(Text, Text)] -> IO (Module, [Operation])
@@ -45,7 +47,7 @@
     Right modul -> do
       let cli = Map.fromList [(VarName k, v) | (k, v) <- vars]
           env = Map.empty
-      case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
+      case resolveVariables (modul ^. #vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
         Left errs -> error ("Failed to resolve: " <> show errs)
         Right resolved -> do
           planResult <- compilePlan (fixtures </> "haskell-base") modul (resolvedValues resolved)
@@ -58,15 +60,15 @@
 manifestWithFiles t recs =
   let base = emptyManifest t
    in Manifest
-        { version = base.version,
-          genAt = base.genAt,
-          modules = base.modules,
-          vars = base.vars,
+        { version = base ^. #version,
+          genAt = base ^. #genAt,
+          modules = base ^. #modules,
+          vars = base ^. #vars,
           files = recs,
-          applications = base.applications,
-          recipe = base.recipe,
-          blueprint = base.blueprint,
-          blueprintMigrations = base.blueprintMigrations
+          applications = base ^. #applications,
+          recipe = base ^. #recipe,
+          blueprint = base ^. #blueprint,
+          blueprintMigrations = base ^. #blueprintMigrations
         }
 
 -- | Extract planned files from operations for computeDiff.
@@ -80,27 +82,27 @@
   describe "full execution pipeline" $ do
     it "first run creates files and builds file records" $ do
       (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
-      let modName = modul.name
+      let modName = (modul ^. #name)
           (records, fs) =
             runPureEff $
               runFilesystemPure emptyFS $
                 executePlan "" ops Map.empty modName fixedTime
       -- Verify files were created in the filesystem
-      Map.member "README.md" (fs.files) `shouldBe` True
-      Map.member "my-app.cabal" (fs.files) `shouldBe` True
-      Map.member "src/Lib.hs" (fs.files) `shouldBe` True
-      Map.member "LICENSE" (fs.files) `shouldBe` True
-      Map.member "cabal.project" (fs.files) `shouldBe` True
+      Map.member "README.md" (fs ^. #files) `shouldBe` True
+      Map.member "my-app.cabal" (fs ^. #files) `shouldBe` True
+      Map.member "src/Lib.hs" (fs ^. #files) `shouldBe` True
+      Map.member "LICENSE" (fs ^. #files) `shouldBe` True
+      Map.member "cabal.project" (fs ^. #files) `shouldBe` True
       -- Verify FileRecords for manifest
       Map.member "README.md" records `shouldBe` True
       Map.member "my-app.cabal" records `shouldBe` True
       Map.member "src/Lib.hs" records `shouldBe` True
       -- Verify content
-      Map.lookup "README.md" (fs.files) `shouldBe` Just "# my-app\n\nVersion: 0.1.0.0\n"
+      Map.lookup "README.md" (fs ^. #files) `shouldBe` Just "# my-app\n\nVersion: 0.1.0.0\n"
 
     it "re-run with same plan shows all unchanged" $ do
       (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
-      let modName = modul.name
+      let modName = (modul ^. #name)
           planned = extractPlanned modName ops
           -- First run: execute to get filesystem state and records
           (records, fs) =
@@ -115,15 +117,15 @@
               runFilesystemPure fs $
                 computeDiff manifest (Set.singleton modName) planned
       -- All files should be unchanged
-      length (diff.new) `shouldBe` 0
-      length (diff.modified) `shouldBe` 0
-      length (diff.conflicts) `shouldBe` 0
-      length (diff.orphaned) `shouldBe` 0
-      length (diff.unchanged) `shouldBe` 5
+      length (diff ^. #new) `shouldBe` 0
+      length (diff ^. #modified) `shouldBe` 0
+      length (diff ^. #conflicts) `shouldBe` 0
+      length (diff ^. #orphaned) `shouldBe` 0
+      length (diff ^. #unchanged) `shouldBe` 5
 
     it "re-run with changed variable shows modified, new, and orphaned" $ do
       (modul, ops1) <- compileFixturePlan [("project.name", "my-app")]
-      let modName = modul.name
+      let modName = (modul ^. #name)
           -- First run
           (records, fs) =
             runPureEff $
@@ -138,17 +140,17 @@
               runFilesystemPure fs $
                 computeDiff manifest (Set.singleton modName) planned2
       -- README.md and cabal.project have different content → Modified
-      length (diff.modified) `shouldBe` 2
+      length (diff ^. #modified) `shouldBe` 2
       -- src/Lib.hs and LICENSE have same content → Unchanged
-      length (diff.unchanged) `shouldBe` 2
+      length (diff ^. #unchanged) `shouldBe` 2
       -- my-app.cabal not in new plan → Orphaned
-      length (diff.orphaned) `shouldBe` 1
-      (head diff.orphaned).path `shouldBe` "my-app.cabal"
+      length (diff ^. #orphaned) `shouldBe` 1
+      ((head (diff ^. #orphaned)) ^. #path) `shouldBe` "my-app.cabal"
       -- other-app.cabal is new → New
-      length (diff.new) `shouldBe` 1
-      (head diff.new).path `shouldBe` "other-app.cabal"
+      length (diff ^. #new) `shouldBe` 1
+      ((head (diff ^. #new)) ^. #path) `shouldBe` "other-app.cabal"
       -- No conflicts
-      length (diff.conflicts) `shouldBe` 0
+      length (diff ^. #conflicts) `shouldBe` 0
 
     it "dryRunPlan lists all operations without execution" $ do
       (_, ops) <- compileFixturePlan [("project.name", "my-app")]
@@ -159,7 +161,7 @@
 
     it "force mode: re-execute after user edit overwrites the file" $ do
       (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
-      let modName = modul.name
+      let modName = (modul ^. #name)
           planned = extractPlanned modName ops
           -- First run
           (records, fs1) =
@@ -168,37 +170,37 @@
                 executePlan "" ops Map.empty modName fixedTime
           manifest = manifestWithFiles fixedTime records
           -- Simulate user editing README.md
-          fs2 = PureFS (Map.insert "README.md" "user edit" fs1.files) fs1.dirs
+          fs2 = PureFS (Map.insert "README.md" "user edit" (fs1 ^. #files)) (fs1 ^. #dirs)
           -- Compute diff → should detect conflict
           (diff, _) =
             runPureEff $
               runFilesystemPure fs2 $
                 computeDiff manifest (Set.singleton modName) planned
       -- README.md is a conflict (user edited, plan unchanged)
-      length (diff.conflicts) `shouldBe` 1
-      (head diff.conflicts).path `shouldBe` "README.md"
+      length (diff ^. #conflicts) `shouldBe` 1
+      ((head (diff ^. #conflicts)) ^. #path) `shouldBe` "README.md"
       -- Force: re-execute (overwrites user changes)
       let (_, fs3) =
             runPureEff $
               runFilesystemPure fs2 $
                 executePlan "" ops Map.empty modName fixedTime
       -- Verify README.md was overwritten with plan content
-      Map.lookup "README.md" fs3.files `shouldBe` Just "# my-app\n\nVersion: 0.1.0.0\n"
+      Map.lookup "README.md" (fs3 ^. #files) `shouldBe` Just "# my-app\n\nVersion: 0.1.0.0\n"
 
     it "records correct strategy per file in FileRecords" $ do
       (modul, ops) <- compileFixturePlan [("project.name", "my-app")]
-      let modName = modul.name
+      let modName = (modul ^. #name)
           (records, _) =
             runPureEff $
               runFilesystemPure emptyFS $
                 executePlan "" ops Map.empty modName fixedTime
       -- README.md → Template
-      (records Map.! "README.md").strategy `shouldBe` Template
+      ((records Map.! "README.md") ^. #strategy) `shouldBe` Template
       -- src/Lib.hs → Template
-      (records Map.! "src/Lib.hs").strategy `shouldBe` Template
+      ((records Map.! "src/Lib.hs") ^. #strategy) `shouldBe` Template
       -- LICENSE → Copy
-      (records Map.! "LICENSE").strategy `shouldBe` Copy
+      ((records Map.! "LICENSE") ^. #strategy) `shouldBe` Copy
       -- my-app.cabal → Template
-      (records Map.! "my-app.cabal").strategy `shouldBe` Template
+      ((records Map.! "my-app.cabal") ^. #strategy) `shouldBe` Template
       -- cabal.project → DhallText
-      (records Map.! "cabal.project").strategy `shouldBe` DhallText
+      ((records Map.! "cabal.project") ^. #strategy) `shouldBe` DhallText
diff --git a/test/Seihou/Integration/GenerationSpec.hs b/test/Seihou/Integration/GenerationSpec.hs
--- a/test/Seihou/Integration/GenerationSpec.hs
+++ b/test/Seihou/Integration/GenerationSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Integration.GenerationSpec (tests) where
 
+import Control.Lens ((&), (.~), (^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Seihou.Core.Module (loadModule)
@@ -22,7 +24,7 @@
 
 -- | Helper to extract the resolved variable values map.
 resolvedValues :: Map.Map VarName ResolvedVar -> Map.Map VarName VarValue
-resolvedValues = Map.map (.value)
+resolvedValues = Map.map (^. #value)
 
 spec :: Spec
 spec = do
@@ -35,7 +37,7 @@
         Right modul -> do
           let cli = Map.fromList [("project.name", "my-app")]
               env = Map.empty
-          case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
+          case resolveVariables (modul ^. #vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
             Left errs -> expectationFailure ("Failed to resolve: " <> show errs)
             Right resolved -> do
               planResult <- compilePlan (fixtures </> "haskell-base") modul (resolvedValues resolved)
@@ -55,7 +57,7 @@
         Right modul -> do
           let cli = Map.fromList [("project.name", "my-app")]
               env = Map.empty
-          case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
+          case resolveVariables (modul ^. #vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
             Left errs -> expectationFailure ("Failed to resolve: " <> show errs)
             Right resolved -> do
               planResult <- compilePlan (fixtures </> "haskell-base") modul (resolvedValues resolved)
@@ -77,7 +79,7 @@
         Right modul -> do
           let cli = Map.fromList [("project.name", "my-app")]
               env = Map.empty
-          case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
+          case resolveVariables (modul ^. #vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
             Left errs -> expectationFailure ("Failed to resolve: " <> show errs)
             Right resolved -> do
               planResult <- compilePlan (fixtures </> "haskell-base") modul (resolvedValues resolved)
@@ -97,7 +99,7 @@
           -- The real scenario: license has a default so it's always set.
           -- To test the conditional, we use a stripped-down module with only the LICENSE step.
           let licenseStep = Step Copy "LICENSE" "LICENSE" (Just (ExprIsSet "license")) Nothing
-              smallModule = modul {steps = [licenseStep]}
+              smallModule = modul & #steps .~ [licenseStep]
               vars = Map.empty -- no license variable set
           planResult <- compilePlan (fixtures </> "haskell-base") smallModule vars
           case planResult of
@@ -114,7 +116,7 @@
         Right modul -> do
           let cli = Map.fromList [("project.name", "my-app")]
               env = Map.empty
-          case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
+          case resolveVariables (modul ^. #vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
             Left errs -> expectationFailure ("Failed to resolve: " <> show errs)
             Right resolved -> do
               planResult <- compilePlan (fixtures </> "haskell-base") modul (resolvedValues resolved)
@@ -135,12 +137,12 @@
           -- CLI overrides project.name, env overrides license
           let cli = Map.fromList [("project.name", "cli-app")]
               env = Map.fromList [("SEIHOU_VAR_LICENSE", "BSD3")]
-          case resolveVariables (modul.vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
+          case resolveVariables (modul ^. #vars) cli env "" "" Map.empty Map.empty Map.empty Map.empty Map.empty of
             Left errs -> expectationFailure ("Failed to resolve: " <> show errs)
             Right resolved -> do
-              (.value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
-              (.source) (resolved Map.! "project.name") `shouldBe` FromCLI
-              (.value) (resolved Map.! "license") `shouldBe` VText "BSD3"
-              (.source) (resolved Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
-              (.value) (resolved Map.! "project.version") `shouldBe` VText "0.1.0.0"
-              (.source) (resolved Map.! "project.version") `shouldBe` FromDefault
+              (^. #value) (resolved Map.! "project.name") `shouldBe` VText "cli-app"
+              (^. #source) (resolved Map.! "project.name") `shouldBe` FromCLI
+              (^. #value) (resolved Map.! "license") `shouldBe` VText "BSD3"
+              (^. #source) (resolved Map.! "license") `shouldBe` FromEnv "SEIHOU_VAR_LICENSE"
+              (^. #value) (resolved Map.! "project.version") `shouldBe` VText "0.1.0.0"
+              (^. #source) (resolved Map.! "project.version") `shouldBe` FromDefault
diff --git a/test/Seihou/Integration/ModuleLoadSpec.hs b/test/Seihou/Integration/ModuleLoadSpec.hs
--- a/test/Seihou/Integration/ModuleLoadSpec.hs
+++ b/test/Seihou/Integration/ModuleLoadSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Integration.ModuleLoadSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Effectful
 -- Re-use the real loader for end-to-end tests
@@ -30,12 +32,12 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got: " <> show err)
         Right m -> do
-          m.name `shouldBe` "haskell-base"
-          m.description `shouldBe` Just "A Haskell project template"
-          length (m.vars) `shouldBe` 3
-          length (m.steps) `shouldBe` 5
-          length (m.prompts) `shouldBe` 1
-          m.dependencies `shouldBe` []
+          (m ^. #name) `shouldBe` "haskell-base"
+          (m ^. #description) `shouldBe` Just "A Haskell project template"
+          length (m ^. #vars) `shouldBe` 3
+          length (m ^. #steps) `shouldBe` 5
+          length (m ^. #prompts) `shouldBe` 1
+          (m ^. #dependencies) `shouldBe` []
 
     it "has correct variable declarations" $ do
       fixtures <- fixtureDir
@@ -43,17 +45,17 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got: " <> show err)
         Right m -> do
-          let vars = m.vars
-          let names = map ((.unVarName) . (.name)) vars
+          let vars = (m ^. #vars)
+          let names = map ((^. #unVarName) . (^. #name)) vars
           names `shouldBe` ["project.name", "project.version", "license"]
 
           let (projectName : projectVersion : license : _) = vars
-          projectName.required `shouldBe` True
-          projectName.default_ `shouldBe` Nothing
+          (projectName ^. #required) `shouldBe` True
+          (projectName ^. #default_) `shouldBe` Nothing
 
-          projectVersion.default_ `shouldBe` Just (VText "0.1.0.0")
+          (projectVersion ^. #default_) `shouldBe` Just (VText "0.1.0.0")
 
-          license.default_ `shouldBe` Just (VText "MIT")
+          (license ^. #default_) `shouldBe` Just (VText "MIT")
 
     it "has a when expression on the LICENSE step" $ do
       fixtures <- fixtureDir
@@ -61,10 +63,10 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got: " <> show err)
         Right m -> do
-          let steps = m.steps
+          let steps = (m ^. #steps)
           let licenseStep = steps !! 2
-          licenseStep.strategy `shouldBe` Copy
-          licenseStep.condition `shouldBe` Just (ExprIsSet "license")
+          (licenseStep ^. #strategy) `shouldBe` Copy
+          (licenseStep ^. #condition) `shouldBe` Just (ExprIsSet "license")
 
     it "has a dest with placeholder variable" $ do
       fixtures <- fixtureDir
@@ -72,8 +74,8 @@
       case result of
         Left err -> expectationFailure ("Expected Right, got: " <> show err)
         Right m -> do
-          let cabalStep = m.steps !! 3
-          cabalStep.dest `shouldBe` "{{project.name}}.cabal"
+          let cabalStep = (m ^. #steps) !! 3
+          (cabalStep ^. #dest) `shouldBe` "{{project.name}}.cabal"
 
   describe "invalid-module" $ do
     it "produces ValidationError with multiple violations" $ do
@@ -90,7 +92,7 @@
       result <- loadModule ["/nonexistent"] "no-such-module"
       case result of
         Left (ModuleNotFound name _) ->
-          name.unModuleName `shouldBe` "no-such-module"
+          (name ^. #unModuleName) `shouldBe` "no-such-module"
         Left other -> expectationFailure ("Expected ModuleNotFound, got: " <> show other)
         Right _ -> expectationFailure "Expected Left"
 
@@ -115,4 +117,4 @@
         evalModuleFile "test/module.dhall"
       case result of
         Left err -> expectationFailure ("Expected Right, got: " <> show err)
-        Right m -> m.name `shouldBe` "test"
+        Right m -> (m ^. #name) `shouldBe` "test"
diff --git a/test/Seihou/Interaction/ConfirmSpec.hs b/test/Seihou/Interaction/ConfirmSpec.hs
--- a/test/Seihou/Interaction/ConfirmSpec.hs
+++ b/test/Seihou/Interaction/ConfirmSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Interaction.ConfirmSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Effectful
 import Seihou.Composition.Instance (primaryInstance)
@@ -68,9 +70,9 @@
       (result, st) <-
         runEff $
           runConsolePure [] $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/base")] resolved
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/base")] resolved
       result `shouldBe` resolved
-      st.consoleOutputs `shouldSatisfy` all (/= "Confirm default values:")
+      (st ^. #outputs) `shouldSatisfy` all (/= "Confirm default values:")
 
     it "prompts for FromDefault variables and accepts Enter as keeping the default" $ do
       let decl = mkTextVar "project.version" (Just (VText "0.1.0.0"))
@@ -82,12 +84,12 @@
       (result, st) <-
         runEff $
           runConsolePure [""] $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/base")] resolved
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/base")] resolved
       let rv = (result Map.! primaryInstance "base") Map.! "project.version"
-      rv.value `shouldBe` VText "0.1.0.0"
-      rv.source `shouldBe` FromDefault
-      st.consoleOutputs `shouldSatisfy` any (== "Confirm default values:")
-      st.consoleOutputs `shouldSatisfy` any (== "project.version [0.1.0.0]:")
+      (rv ^. #value) `shouldBe` VText "0.1.0.0"
+      (rv ^. #source) `shouldBe` FromDefault
+      (st ^. #outputs) `shouldSatisfy` any (== "Confirm default values:")
+      (st ^. #outputs) `shouldSatisfy` any (== "project.version [0.1.0.0]:")
 
     it "replaces the value and marks source as FromPrompt when user types a new value" $ do
       let decl = mkTextVar "project.version" (Just (VText "0.1.0.0"))
@@ -99,10 +101,10 @@
       (result, _st) <-
         runEff $
           runConsolePure ["1.0.0"] $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/base")] resolved
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/base")] resolved
       let rv = (result Map.! primaryInstance "base") Map.! "project.version"
-      rv.value `shouldBe` VText "1.0.0"
-      rv.source `shouldBe` FromPrompt
+      (rv ^. #value) `shouldBe` VText "1.0.0"
+      (rv ^. #source) `shouldBe` FromPrompt
 
     it "retries on invalid input and keeps the default on final failure" $ do
       let decl = mkIntVar "retry.count" (Just (VInt 42))
@@ -114,10 +116,10 @@
       (result, _st) <-
         runEff $
           runConsolePure ["not-an-int", "still-bad", "nope"] $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/base")] resolved
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/base")] resolved
       let rv = (result Map.! primaryInstance "base") Map.! "retry.count"
-      rv.value `shouldBe` VInt 42
-      rv.source `shouldBe` FromDefault
+      (rv ^. #value) `shouldBe` VInt 42
+      (rv ^. #source) `shouldBe` FromDefault
 
     it "prompts for FromParent variables" $ do
       let decl = mkTextVar "skill.name" (Just (VText "exec-plan"))
@@ -132,10 +134,10 @@
       (result, _st) <-
         runEff $
           runConsolePure ["override"] $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/child")] resolved
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/child")] resolved
       let rv = (result Map.! primaryInstance "child") Map.! "skill.name"
-      rv.value `shouldBe` VText "override"
-      rv.source `shouldBe` FromPrompt
+      (rv ^. #value) `shouldBe` VText "override"
+      (rv ^. #source) `shouldBe` FromPrompt
 
     it "is a no-op in non-interactive mode" $ do
       let decl = mkTextVar "project.version" (Just (VText "0.1.0.0"))
@@ -147,9 +149,9 @@
       (result, st) <-
         runEff $
           runConsolePureNonInteractive $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/base")] resolved
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/base")] resolved
       result `shouldBe` resolved
-      st.consoleOutputs `shouldBe` []
+      (st ^. #outputs) `shouldBe` []
 
     it "uses authored Prompt text when available" $ do
       let decl = mkTextVar "license" (Just (VText "MIT"))
@@ -168,5 +170,5 @@
       (_result, st) <-
         runEff $
           runConsolePure [""] $
-            confirmDefaults [(primaryInstance m.name, m, "/fake/base")] resolved
-      st.consoleOutputs `shouldSatisfy` any (== "Choose a license [MIT]:")
+            confirmDefaults [(primaryInstance (m ^. #name), m, "/fake/base")] resolved
+      (st ^. #outputs) `shouldSatisfy` any (== "Choose a license [MIT]:")
diff --git a/test/Seihou/Interaction/PromptSpec.hs b/test/Seihou/Interaction/PromptSpec.hs
--- a/test/Seihou/Interaction/PromptSpec.hs
+++ b/test/Seihou/Interaction/PromptSpec.hs
@@ -1,5 +1,7 @@
 module Seihou.Interaction.PromptSpec (tests) where
 
+import Control.Lens ((^.))
+import Data.Generics.Labels ()
 import Data.Map.Strict qualified as Map
 import Data.Text (Text)
 import Effectful
@@ -103,10 +105,10 @@
             runPrompts [prompt] [decl] bindings
       Map.member "project.name" result `shouldBe` True
       let rv = result Map.! "project.name"
-      rv.value `shouldBe` VText "my-app"
-      rv.source `shouldBe` FromPrompt
+      (rv ^. #value) `shouldBe` VText "my-app"
+      (rv ^. #source) `shouldBe` FromPrompt
       -- The prompt text should have been output
-      st.consoleOutputs `shouldSatisfy` any (== "What is the project name?")
+      (st ^. #outputs) `shouldSatisfy` any (== "What is the project name?")
 
     it "fills a prompt with choices via selection number" $ do
       let decl = mkTextVar "license" Nothing True
@@ -117,8 +119,8 @@
           runConsolePure ["2"] $
             runPrompts [prompt] [decl] bindings
       Map.member "license" result `shouldBe` True
-      (result Map.! "license").value `shouldBe` VText "Apache-2.0"
-      (result Map.! "license").source `shouldBe` FromPrompt
+      ((result Map.! "license") ^. #value) `shouldBe` VText "Apache-2.0"
+      ((result Map.! "license") ^. #source) `shouldBe` FromPrompt
 
     it "skips a prompt whose when condition evaluates to False" $ do
       let decl = mkTextVar "extra.flag" Nothing True
@@ -132,7 +134,7 @@
       -- Prompt was skipped, so the variable is not resolved
       Map.member "extra.flag" result `shouldBe` False
       -- No prompt text was output
-      st.consoleOutputs `shouldSatisfy` all (/= "Extra flag?")
+      (st ^. #outputs) `shouldSatisfy` all (/= "Extra flag?")
 
     it "shows a prompt whose when condition evaluates to True" $ do
       let decl = mkTextVar "extra.flag" Nothing True
@@ -144,7 +146,7 @@
           runConsolePure ["some-value"] $
             runPrompts [prompt] [decl] bindings
       Map.member "extra.flag" result `shouldBe` True
-      (result Map.! "extra.flag").value `shouldBe` VText "some-value"
+      ((result Map.! "extra.flag") ^. #value) `shouldBe` VText "some-value"
 
     it "skips a prompt for a variable not in the unresolved set" $ do
       let decl = mkTextVar "project.name" Nothing True
@@ -156,7 +158,7 @@
           runConsolePure ["anything"] $
             runPrompts [prompt] [decl] bindings
       Map.null result `shouldBe` True
-      st.consoleOutputs `shouldSatisfy` all (/= "Other?")
+      (st ^. #outputs) `shouldSatisfy` all (/= "Other?")
 
   describe "default value display" $ do
     it "shows default value in prompt text and accepts Enter" $ do
@@ -169,10 +171,10 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right rv -> do
-          rv.value `shouldBe` VText "0.1.0.0"
-          rv.source `shouldBe` FromPrompt
+          (rv ^. #value) `shouldBe` VText "0.1.0.0"
+          (rv ^. #source) `shouldBe` FromPrompt
       -- Prompt text should include the default in brackets
-      st.consoleOutputs `shouldSatisfy` any (== "Project version [0.1.0.0]:")
+      (st ^. #outputs) `shouldSatisfy` any (== "Project version [0.1.0.0]:")
 
     it "accepts user input over default when provided" $ do
       let decl = mkTextVar "project.version" (Just (VText "0.1.0.0")) True
@@ -184,8 +186,8 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right rv ->
-          rv.value `shouldBe` VText "1.0.0"
-      st.consoleOutputs `shouldSatisfy` any (== "Project version [0.1.0.0]:")
+          (rv ^. #value) `shouldBe` VText "1.0.0"
+      (st ^. #outputs) `shouldSatisfy` any (== "Project version [0.1.0.0]:")
 
     it "shows [skip] for optional variable without default" $ do
       let decl = mkTextVar "license" Nothing False
@@ -194,7 +196,7 @@
         runEff $
           runConsolePure [""] $
             promptForVar prompt decl Map.empty
-      st.consoleOutputs `shouldSatisfy` any (== "License [skip]:")
+      (st ^. #outputs) `shouldSatisfy` any (== "License [skip]:")
 
     it "shows bool default as yes/no" $ do
       let decl = mkBoolVar "enable.ci" (Just (VBool True)) False
@@ -206,8 +208,8 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right rv ->
-          rv.value `shouldBe` VBool True
-      st.consoleOutputs `shouldSatisfy` any (== "Enable CI? [yes]:")
+          (rv ^. #value) `shouldBe` VBool True
+      (st ^. #outputs) `shouldSatisfy` any (== "Enable CI? [yes]:")
 
   describe "promptForVar" $ do
     it "coerces boolean input correctly" $ do
@@ -220,8 +222,8 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right rv -> do
-          rv.value `shouldBe` VBool True
-          rv.source `shouldBe` FromPrompt
+          (rv ^. #value) `shouldBe` VBool True
+          (rv ^. #source) `shouldBe` FromPrompt
 
     it "coerces 'no' to False for boolean variable" $ do
       let decl = mkBoolVar "use.ci" Nothing True
@@ -233,7 +235,7 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right rv ->
-          rv.value `shouldBe` VBool False
+          (rv ^. #value) `shouldBe` VBool False
 
     it "retries on empty input then succeeds" $ do
       let decl = mkTextVar "project.name" Nothing True
@@ -245,9 +247,9 @@
       case result of
         Left err -> expectationFailure $ "Expected Right, got: " ++ show err
         Right rv ->
-          rv.value `shouldBe` VText "my-app"
+          (rv ^. #value) `shouldBe` VText "my-app"
       -- Should have output a retry message
-      st.consoleOutputs `shouldSatisfy` any (== "Value cannot be empty. Please try again.")
+      (st ^. #outputs) `shouldSatisfy` any (== "Value cannot be empty. Please try again.")
 
     it "fails after exhausting retries on empty input" $ do
       let decl = mkTextVar "project.name" Nothing True
@@ -270,7 +272,7 @@
               [mkTextVar "project.name" Nothing True]
               []
               [mkPrompt "project.name" "What is the project name?"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePure ["my-app"] $
@@ -279,9 +281,9 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right resolved -> do
           let baseVars = resolved Map.! primaryInstance "base"
-          (baseVars Map.! "project.name").value `shouldBe` VText "my-app"
-          (baseVars Map.! "project.name").source `shouldBe` FromPrompt
-      st.consoleOutputs `shouldSatisfy` any (== "What is the project name?")
+          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
+          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromPrompt
+      (st ^. #outputs) `shouldSatisfy` any (== "What is the project name?")
 
     it "does not prompt when all variables are provided via CLI" $ do
       let m =
@@ -291,7 +293,7 @@
               [mkTextVar "project.name" Nothing True]
               []
               [mkPrompt "project.name" "What is the project name?"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
           cliOverrides = Map.singleton "project.name" "from-cli"
       (result, st) <-
         runEff $
@@ -301,10 +303,10 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right resolved -> do
           let baseVars = resolved Map.! primaryInstance "base"
-          (baseVars Map.! "project.name").value `shouldBe` VText "from-cli"
-          (baseVars Map.! "project.name").source `shouldBe` FromCLI
+          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "from-cli"
+          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromCLI
       -- No prompts should have been displayed
-      st.consoleOutputs `shouldSatisfy` all (/= "What is the project name?")
+      (st ^. #outputs) `shouldSatisfy` all (/= "What is the project name?")
 
     it "skips prompts and errors in non-interactive mode" $ do
       let m =
@@ -314,7 +316,7 @@
               [mkTextVar "project.name" Nothing True]
               []
               [mkPrompt "project.name" "What is the project name?"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePureNonInteractive $
@@ -326,7 +328,7 @@
           _ -> expectationFailure $ "Expected exactly 1 error, got: " ++ show (length errs)
         Right _ -> expectationFailure "Expected Left (errors), got Right"
       -- No prompts should have been displayed
-      st.consoleOutputs `shouldSatisfy` all (/= "What is the project name?")
+      (st ^. #outputs) `shouldSatisfy` all (/= "What is the project name?")
 
     it "forbids prompts even when the Console interpreter is interactive" $ do
       let m =
@@ -336,14 +338,14 @@
               [mkTextVar "project.name" Nothing True]
               []
               [mkPrompt "project.name" "What is the project name?"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePure ["must-not-be-read"] $
             resolveWithPromptPermission PromptsForbidden modules Map.empty Map.empty Map.empty "" "" Map.empty Map.empty Map.empty Map.empty
       result `shouldBe` Left [MissingRequiredVar "project.name"]
-      st.consoleInputs `shouldBe` ["must-not-be-read"]
-      st.consoleOutputs `shouldSatisfy` all (/= "What is the project name?")
+      (st ^. #inputs) `shouldBe` ["must-not-be-read"]
+      (st ^. #outputs) `shouldSatisfy` all (/= "What is the project name?")
 
     it "prompts for optional variables after required resolution" $ do
       let m =
@@ -357,7 +359,7 @@
               [ mkPrompt "project.name" "What is the project name?",
                 mkPrompt "license" "License"
               ]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePure ["my-app", "MIT"] $
@@ -366,11 +368,11 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right resolved -> do
           let baseVars = resolved Map.! primaryInstance "base"
-          (baseVars Map.! "project.name").value `shouldBe` VText "my-app"
-          (baseVars Map.! "project.name").source `shouldBe` FromPrompt
-          (baseVars Map.! "license").value `shouldBe` VText "MIT"
-          (baseVars Map.! "license").source `shouldBe` FromPrompt
-      st.consoleOutputs `shouldSatisfy` any (== "Optional configuration:")
+          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
+          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromPrompt
+          ((baseVars Map.! "license") ^. #value) `shouldBe` VText "MIT"
+          ((baseVars Map.! "license") ^. #source) `shouldBe` FromPrompt
+      (st ^. #outputs) `shouldSatisfy` any (== "Optional configuration:")
 
     it "skips optional variable when user presses Enter" $ do
       let m =
@@ -384,7 +386,7 @@
               [ mkPrompt "project.name" "What is the project name?",
                 mkPrompt "license" "License"
               ]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, _st) <-
         runEff $
           runConsolePure ["my-app", ""] $
@@ -404,7 +406,7 @@
               [mkTextVar "license" Nothing False]
               []
               [mkPrompt "license" "License"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePure ["MIT"] $
@@ -413,8 +415,8 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right resolved -> do
           let baseVars = resolved Map.! primaryInstance "base"
-          (baseVars Map.! "license").value `shouldBe` VText "MIT"
-      st.consoleOutputs `shouldSatisfy` any (== "Optional configuration:")
+          ((baseVars Map.! "license") ^. #value) `shouldBe` VText "MIT"
+      (st ^. #outputs) `shouldSatisfy` any (== "Optional configuration:")
 
     it "does not show optional prompts in non-interactive mode" $ do
       let m =
@@ -424,7 +426,7 @@
               [mkTextVar "license" Nothing False]
               []
               [mkPrompt "license" "License"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePureNonInteractive $
@@ -434,7 +436,7 @@
         Right resolved -> do
           let baseVars = resolved Map.! primaryInstance "base"
           Map.member "license" baseVars `shouldBe` False
-      st.consoleOutputs `shouldSatisfy` all (/= "Optional configuration:")
+      (st ^. #outputs) `shouldSatisfy` all (/= "Optional configuration:")
 
     it "respects when condition on optional prompts" $ do
       let m =
@@ -448,7 +450,7 @@
               [ mkPrompt "project.name" "Name?",
                 mkConditionalPrompt "extra" "Extra?" (ExprIsSet "nonexistent")
               ]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
       (result, st) <-
         runEff $
           runConsolePure ["my-app"] $
@@ -460,7 +462,7 @@
           Map.member "extra" baseVars `shouldBe` False
       -- The condition was false so Optional configuration header should not appear
       -- (no optional prompts actually fired)
-      st.consoleOutputs `shouldSatisfy` all (/= "Extra?")
+      (st ^. #outputs) `shouldSatisfy` all (/= "Extra?")
 
     it "does not prompt for optional variables already resolved via config" $ do
       let m =
@@ -470,7 +472,7 @@
               [mkTextVar "license" Nothing False]
               []
               [mkPrompt "license" "License"]
-          modules = [(primaryInstance m.name, m, "/fake/base")]
+          modules = [(primaryInstance (m ^. #name), m, "/fake/base")]
           globalConfig = Map.singleton "license" "MIT"
       (result, st) <-
         runEff $
@@ -480,9 +482,9 @@
         Left errs -> expectationFailure $ "Expected Right, got: " ++ show errs
         Right resolved -> do
           let baseVars = resolved Map.! primaryInstance "base"
-          (baseVars Map.! "license").value `shouldBe` VText "MIT"
-          (baseVars Map.! "license").source `shouldBe` FromGlobalConfig
-      st.consoleOutputs `shouldSatisfy` all (/= "Optional configuration:")
+          ((baseVars Map.! "license") ^. #value) `shouldBe` VText "MIT"
+          ((baseVars Map.! "license") ^. #source) `shouldBe` FromGlobalConfig
+      (st ^. #outputs) `shouldSatisfy` all (/= "Optional configuration:")
 
     it "flows prompted value from first module to second via exports" $ do
       let base =
@@ -499,7 +501,7 @@
               [mkTextVar "project.name" Nothing True]
               []
               []
-          modules = [(primaryInstance base.name, base, "/fake/base"), (primaryInstance app.name, app, "/fake/app")]
+          modules = [(primaryInstance (base ^. #name), base, "/fake/base"), (primaryInstance (app ^. #name), app, "/fake/app")]
       (result, st) <-
         runEff $
           runConsolePure ["my-app"] $
@@ -509,11 +511,11 @@
         Right resolved -> do
           -- Base module was prompted
           let baseVars = resolved Map.! primaryInstance "base"
-          (baseVars Map.! "project.name").value `shouldBe` VText "my-app"
-          (baseVars Map.! "project.name").source `shouldBe` FromPrompt
+          ((baseVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
+          ((baseVars Map.! "project.name") ^. #source) `shouldBe` FromPrompt
           -- App module received the value via export (no additional prompt needed)
           let appVars = resolved Map.! primaryInstance "app"
-          (appVars Map.! "project.name").value `shouldBe` VText "my-app"
+          ((appVars Map.! "project.name") ^. #value) `shouldBe` VText "my-app"
       -- Only one prompt should have fired (for base), not two
-      let promptOutputs = filter (== "What is the project name?") (st.consoleOutputs)
+      let promptOutputs = filter (== "What is the project name?") (st ^. #outputs)
       length promptOutputs `shouldBe` 1
diff --git a/test/Seihou/Manifest/TypesSpec.hs b/test/Seihou/Manifest/TypesSpec.hs
--- a/test/Seihou/Manifest/TypesSpec.hs
+++ b/test/Seihou/Manifest/TypesSpec.hs
@@ -1,6 +1,14 @@
 module Seihou.Manifest.TypesSpec (tests) where
 
+import Control.Lens ((%~), (&), (.~), (^.))
+import Control.Monad (forM_)
 import Data.Aeson qualified as Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.ByteString.Lazy.Char8 qualified as LBS8
+import Data.Foldable (toList)
+import Data.Generics.Labels ()
+import Data.List (isInfixOf)
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text qualified as T
@@ -36,23 +44,255 @@
 -- | Helper to set modules on a Manifest without ambiguous record update.
 withManifestModules :: [AppliedModule] -> Manifest -> Manifest
 withManifestModules mods m =
-  Manifest m.version m.genAt mods m.vars m.files m.applications m.recipe m.blueprint m.blueprintMigrations
+  Manifest (m ^. #version) (m ^. #genAt) mods (m ^. #vars) (m ^. #files) (m ^. #applications) (m ^. #recipe) (m ^. #blueprint) (m ^. #blueprintMigrations)
 
+-- | Every string that appears anywhere inside a value keyed @origin@ or
+-- @targetOrigin@, at any depth.
+originStrings :: Aeson.Value -> [T.Text]
+originStrings = go False
+  where
+    go inOrigin value = case value of
+      Aeson.Object object ->
+        concat
+          [ go (inOrigin || Key.toText key `elem` (["origin", "targetOrigin"] :: [T.Text])) child
+          | (key, child) <- KeyMap.toList object
+          ]
+      Aeson.Array items -> concatMap (go inOrigin) (toList items)
+      Aeson.String text -> [text | inOrigin]
+      _ -> []
+
+-- | A manifest exercising every serialized origin position at once.
+manifestWithEveryOriginPosition :: Manifest
+manifestWithEveryOriginPosition =
+  (emptyManifest fixedTime)
+    & #modules
+      .~ [ AppliedModule
+             { name = ModuleName "haskell-base",
+               parentVars = emptyParentVars,
+               origin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules"),
+               moduleVersion = Just "1.4.0",
+               appliedAt = fixedTime,
+               removal = Nothing
+             }
+         ]
+    & #applications
+      .~ [ AppliedComposition
+             { applicationId = ApplicationId "app",
+               target = AppliedModuleTarget (ModuleName "haskell-base"),
+               targetOrigin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules"),
+               targetVersion = Just "1.4.0",
+               additionalModules = [],
+               namespace = Nothing,
+               context = Nothing,
+               instances =
+                 [ AppliedInstanceState
+                     { name = ModuleName "docs",
+                       parentVars = emptyParentVars,
+                       origin = ProjectOrigin ".seihou/modules/docs",
+                       moduleVersion = Just "0.1.0",
+                       resolvedVars = Map.empty
+                     },
+                   AppliedInstanceState
+                     { name = ModuleName "scratch",
+                       parentVars = emptyParentVars,
+                       origin = LocalOrigin "scratch",
+                       moduleVersion = Nothing,
+                       resolvedVars = Map.empty
+                     }
+                 ],
+               commandReceipts = Map.empty,
+               appliedAt = fixedTime
+             }
+         ]
+
+-- | A manifest populated in every serialized position that can hold a string,
+-- so the machine-independence sweep has something to sweep.
+--
+-- Deliberately broader than 'manifestWithEveryOriginPosition': that one proves
+-- the origin fields are portable, this one proves nothing /else/ smuggles a
+-- path in — a file record and its baseline, a command receipt with a working
+-- directory, a removal spec, an applied recipe, an applied blueprint, and a
+-- blueprint migration receipt.
+manifestWithEveryStringPosition :: Manifest
+manifestWithEveryStringPosition =
+  manifestWithEveryOriginPosition
+    & #modules
+      .~ [ AppliedModule
+             { name = ModuleName "haskell-base",
+               parentVars = ParentVars (Map.singleton (VarName "project.name") "demo"),
+               origin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules"),
+               moduleVersion = Just "1.4.0",
+               appliedAt = fixedTime,
+               removal =
+                 Just
+                   ( Removal
+                       [RemovalStep RemoveFileAction "flake.nix" (Just "files/flake.nix")]
+                       [Command "cabal clean" (Just "backend") Nothing]
+                   )
+             }
+         ]
+    & #vars .~ Map.singleton (VarName "project.name") "demo"
+    & #files
+      .~ Map.singleton
+        "backend/flake.nix"
+        ( FileRecord
+            (hashContent "flake")
+            (ModuleName "haskell-base")
+            DhallText
+            fixedTime
+            (Just (BaselineRef (hashContent "flake")))
+            (Set.singleton (ApplicationId "app"))
+        )
+    & #applications
+      %~ map (withCommandReceipts (Map.singleton receiptFingerprint receipt))
+    & #recipe .~ Just (AppliedRecipe (RecipeName "haskell-service") (Just "3.1.0") fixedTime)
+    & #blueprint
+      .~ Just
+        ( AppliedBlueprint
+            { name = ModuleName "service-blueprint",
+              blueprintVersion = Just "2.0.0",
+              appliedAt = fixedTime,
+              baselineModules = [ModuleName "haskell-base"],
+              noBaseline = False,
+              userPrompt = Just "build a service",
+              agentSessionId = Just "session-abc"
+            }
+        )
+    & #blueprintMigrations .~ [mkBlueprintMigrationReceipt "service-blueprint" "1.0.0" "2.0.0" fixedTime2]
+  where
+    receiptFingerprint = CommandFingerprint (hashContent "cabal build")
+    receipt = CommandReceipt receiptFingerprint (ModuleName "haskell-base") "cabal build" (Just "backend") fixedTime
+
+-- | Set an application's command receipts without record update syntax.
+withCommandReceipts :: Map.Map CommandFingerprint CommandReceipt -> AppliedComposition -> AppliedComposition
+withCommandReceipts receipts composition =
+  AppliedComposition
+    { applicationId = composition ^. #applicationId,
+      target = composition ^. #target,
+      targetOrigin = composition ^. #targetOrigin,
+      targetVersion = composition ^. #targetVersion,
+      additionalModules = composition ^. #additionalModules,
+      namespace = composition ^. #namespace,
+      context = composition ^. #context,
+      instances = composition ^. #instances,
+      commandReceipts = receipts,
+      appliedAt = composition ^. #appliedAt
+    }
+
+-- | Every string in a document, each paired with the JSON path that reaches
+-- it, so a failure can say /where/ the offending value was.
+--
+-- Object keys are reported too: the @files@ map is keyed by destination path,
+-- which is exactly the sort of place an absolute path could reappear.
+documentStrings :: Aeson.Value -> [(String, T.Text)]
+documentStrings = go "$"
+  where
+    go path value = case value of
+      Aeson.Object object ->
+        concat
+          [ (path <> "." <> T.unpack (Key.toText key), Key.toText key)
+              : go (path <> "." <> T.unpack (Key.toText key)) child
+          | (key, child) <- KeyMap.toList object
+          ]
+      Aeson.Array items ->
+        concat [go (path <> "[" <> show index <> "]") item | (index, item) <- zip [(0 :: Int) ..] (toList items)]
+      Aeson.String text -> [(path, text)]
+      _ -> []
+
+-- | Whether a string only means something on the machine that wrote it: a
+-- POSIX absolute path, a home-relative path, a UNC share, or a Windows drive
+-- prefix.
+machineSpecific :: T.Text -> Bool
+machineSpecific text =
+  T.isPrefixOf "/" text
+    || T.isPrefixOf "~" text
+    || T.isPrefixOf "\\\\" text
+    || (T.length text >= 3 && T.index text 1 == ':' && T.index text 2 == '\\')
+
 spec :: Spec
 spec = do
+  -- The manifest is checked into version control and read on other machines,
+  -- so no origin it records may name a location that only exists on the
+  -- machine that wrote it. See
+  -- docs/adr/0001-manifest-is-a-checked-in-machine-independent-artifact.md.
+  describe "machine independence" $ do
+    -- The two specs below constrain the origin fields. This one constrains
+    -- every future field as well: a new manifest field that records a location
+    -- has to express it relative to the project root or through an
+    -- ArtifactOrigin, and this fails if one does neither.
+    it "records no machine-specific value anywhere in the document" $ do
+      let encoded = Aeson.toJSON manifestWithEveryStringPosition
+          scanned = documentStrings encoded
+          offenders =
+            [ path <> " = " <> T.unpack text
+            | (path, text) <- scanned,
+              machineSpecific text
+            ]
+      length scanned `shouldSatisfy` (> 20)
+      offenders `shouldBe` []
+
+    it "records no absolute path in any origin position" $ do
+      let encoded = Aeson.toJSON manifestWithEveryOriginPosition
+          strings = originStrings encoded
+      strings `shouldSatisfy` not . null
+      forM_ strings $ \text -> do
+        T.isPrefixOf "/" text `shouldBe` False
+        T.isPrefixOf "~" text `shouldBe` False
+        (T.length text >= 2 && T.index text 1 == ':') `shouldBe` False
+
+    it "does not serialize the in-memory source path at all" $ do
+      let encoded = LBS8.unpack (manifestToJSON manifestWithEveryOriginPosition)
+      encoded `shouldSatisfy` not . isInfixOf "/Users/someone"
+      encoded `shouldSatisfy` not . isInfixOf "\"source\""
+      encoded `shouldSatisfy` not . isInfixOf "\"targetSource\""
+
   describe "emptyManifest" $ do
     it "creates a manifest with the current version" $ do
       let m = emptyManifest fixedTime
-      m.version `shouldBe` currentManifestVersion
-      m.version `shouldBe` 5
+      (m ^. #version) `shouldBe` currentManifestVersion
+      (m ^. #version) `shouldBe` 6
 
     it "creates a manifest with no modules, vars, or files" $ do
       let m = emptyManifest fixedTime
-      m.modules `shouldBe` []
-      m.vars `shouldBe` Map.empty
-      m.files `shouldBe` Map.empty
-      m.blueprintMigrations `shouldBe` []
+      (m ^. #modules) `shouldBe` []
+      (m ^. #vars) `shouldBe` Map.empty
+      (m ^. #files) `shouldBe` Map.empty
+      (m ^. #blueprintMigrations) `shouldBe` []
 
+  describe "ArtifactOrigin" $ do
+    it "roundtrips a remote origin carrying a repository name" $ do
+      let origin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules")
+      Aeson.decode (Aeson.encode origin) `shouldBe` Just origin
+
+    it "roundtrips a remote origin with no repository name" $ do
+      let origin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" Nothing
+      Aeson.decode (Aeson.encode origin) `shouldBe` Just origin
+
+    it "omits the repo key entirely when there is no repository name" $ do
+      let origin = RemoteOrigin "https://example.com/mods.git" "haskell-base" Nothing
+      Aeson.toJSON origin
+        `shouldBe` Aeson.object
+          [ "kind" Aeson..= ("remote" :: T.Text),
+            "url" Aeson..= ("https://example.com/mods.git" :: T.Text),
+            "artifact" Aeson..= ("haskell-base" :: T.Text)
+          ]
+
+    it "roundtrips a project origin" $ do
+      let origin = ProjectOrigin ".seihou/modules/demo"
+      Aeson.decode (Aeson.encode origin) `shouldBe` Just origin
+
+    it "roundtrips a local origin" $ do
+      let origin = LocalOrigin "scratch-module"
+      Aeson.decode (Aeson.encode origin) `shouldBe` Just origin
+
+    it "rejects an unknown origin kind" $ do
+      (Aeson.decode "{\"kind\":\"martian\"}" :: Maybe ArtifactOrigin) `shouldBe` Nothing
+
+    it "names the artifact each origin refers to" $ do
+      artifactOriginName (RemoteOrigin "https://example.com/mods.git" "haskell-base" Nothing) `shouldBe` "haskell-base"
+      artifactOriginName (LocalOrigin "scratch-module") `shouldBe` "scratch-module"
+      artifactOriginName (ProjectOrigin ".seihou/modules/demo") `shouldBe` "demo"
+
   describe "JSON roundtrip" $ do
     it "roundtrips an empty manifest" $ do
       let m = emptyManifest fixedTime
@@ -64,7 +304,7 @@
               [ AppliedModule
                   { name = ModuleName "haskell-base",
                     parentVars = emptyParentVars,
-                    source = "/home/user/.config/seihou/modules/haskell-base",
+                    origin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules"),
                     moduleVersion = Nothing,
                     appliedAt = fixedTime,
                     removal = Nothing
@@ -77,16 +317,16 @@
       let base = emptyManifest fixedTime
           m =
             Manifest
-              { version = base.version,
-                genAt = base.genAt,
-                modules = base.modules,
+              { version = base ^. #version,
+                genAt = base ^. #genAt,
+                modules = base ^. #modules,
                 vars =
                   Map.fromList
                     [ (VarName "project.name", "my-app"),
                       (VarName "license", "MIT")
                     ],
-                files = base.files,
-                applications = base.applications,
+                files = base ^. #files,
+                applications = base ^. #applications,
                 recipe = Nothing,
                 blueprint = Nothing,
                 blueprintMigrations = []
@@ -96,31 +336,9 @@
     it "roundtrips a manifest with file records" $ do
       let m :: Manifest
           m =
-            (emptyManifest fixedTime)
-              { files =
-                  Map.fromList
-                    [ ( "README.md",
-                        FileRecord
-                          { hash = SHA256 "abc123",
-                            moduleName = ModuleName "haskell-base",
-                            strategy = Template,
-                            generatedAt = fixedTime,
-                            baseline = Nothing,
-                            applicationIds = mempty
-                          }
-                      ),
-                      ( "my-app.cabal",
-                        FileRecord
-                          { hash = SHA256 "def456",
-                            moduleName = ModuleName "haskell-base",
-                            strategy = DhallText,
-                            generatedAt = fixedTime,
-                            baseline = Nothing,
-                            applicationIds = mempty
-                          }
-                      )
-                    ]
-              }
+            ( (emptyManifest fixedTime)
+                & #files .~ Map.fromList [("README.md", FileRecord {hash = SHA256 "abc123", moduleName = ModuleName "haskell-base", strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty}), ("my-app.cabal", FileRecord {hash = SHA256 "def456", moduleName = ModuleName "haskell-base", strategy = DhallText, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty})]
+            )
       manifestFromJSON (manifestToJSON m) `shouldBe` Right m
 
     it "roundtrips a full manifest" $ do
@@ -129,8 +347,8 @@
               { version = currentManifestVersion,
                 genAt = fixedTime,
                 modules =
-                  [ AppliedModule (ModuleName "haskell-base") emptyParentVars "/path/to/module" Nothing fixedTime Nothing,
-                    AppliedModule (ModuleName "nix-flake") emptyParentVars "/path/to/nix" Nothing fixedTime2 Nothing
+                  [ AppliedModule (ModuleName "haskell-base") emptyParentVars (LocalOrigin "haskell-base") Nothing fixedTime Nothing,
+                    AppliedModule (ModuleName "nix-flake") emptyParentVars (LocalOrigin "nix-flake") Nothing fixedTime2 Nothing
                   ],
                 vars =
                   Map.fromList
@@ -159,11 +377,9 @@
             FileRecord (SHA256 "hash") (ModuleName "mod") s fixedTime Nothing mempty
           m :: Manifest
           m =
-            (emptyManifest fixedTime)
-              { files =
-                  Map.fromList
-                    (zipWith (\i s -> ("file" <> show i, makeRecord s)) [(1 :: Int) ..] strategies)
-              }
+            ( (emptyManifest fixedTime)
+                & #files .~ Map.fromList (zipWith (\i s -> ("file" <> show i, makeRecord s)) [(1 :: Int) ..] strategies)
+            )
       manifestFromJSON (manifestToJSON m) `shouldBe` Right m
 
     it "roundtrips a manifest with versioned modules" $ do
@@ -172,7 +388,7 @@
               [ AppliedModule
                   { name = ModuleName "haskell-base",
                     parentVars = emptyParentVars,
-                    source = "/path/to/module",
+                    origin = LocalOrigin "haskell-base",
                     moduleVersion = Just "1.0.0",
                     appliedAt = fixedTime,
                     removal = Nothing
@@ -187,7 +403,7 @@
               [ AppliedModule
                   { name = ModuleName "simple-mod",
                     parentVars = emptyParentVars,
-                    source = "/path/to/mod",
+                    origin = LocalOrigin "simple-mod",
                     moduleVersion = Nothing,
                     appliedAt = fixedTime,
                     removal = Nothing
@@ -204,7 +420,7 @@
               [ AppliedModule
                   { name = ModuleName "claude-skill-link",
                     parentVars = pv1,
-                    source = "/modules/claude-skill-link",
+                    origin = ProjectOrigin ".seihou/modules/claude-skill-link",
                     moduleVersion = Nothing,
                     appliedAt = fixedTime,
                     removal = Nothing
@@ -212,7 +428,7 @@
                 AppliedModule
                   { name = ModuleName "claude-skill-link",
                     parentVars = pv2,
-                    source = "/modules/claude-skill-link",
+                    origin = ProjectOrigin ".seihou/modules/claude-skill-link",
                     moduleVersion = Nothing,
                     appliedAt = fixedTime,
                     removal = Nothing
@@ -239,14 +455,14 @@
             AppliedComposition
               { applicationId = appId1,
                 target = AppliedModuleTarget (ModuleName "master-plan"),
-                targetSource = "/modules/master-plan",
+                targetOrigin = ProjectOrigin ".seihou/modules/master-plan",
                 targetVersion = Just "0.7.0",
                 additionalModules = [ModuleName "docs"],
                 namespace = Just "planning",
                 context = Just "work",
                 instances =
-                  [ AppliedInstanceState (ModuleName "link-skill") pv1 "/modules/link-skill" (Just "1") (Map.singleton (VarName "skill.name") "exec-plan"),
-                    AppliedInstanceState (ModuleName "link-skill") pv2 "/modules/link-skill" (Just "1") (Map.singleton (VarName "skill.name") "master-plan")
+                  [ AppliedInstanceState (ModuleName "link-skill") pv1 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "exec-plan"),
+                    AppliedInstanceState (ModuleName "link-skill") pv2 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "master-plan")
                   ],
                 commandReceipts = Map.singleton fingerprint receipt,
                 appliedAt = fixedTime
@@ -255,7 +471,7 @@
             AppliedComposition
               { applicationId = appId2,
                 target = AppliedRecipeTarget (RecipeName "service"),
-                targetSource = "/recipes/service",
+                targetOrigin = LocalOrigin "service",
                 targetVersion = Nothing,
                 additionalModules = [],
                 namespace = Nothing,
@@ -274,10 +490,10 @@
                 applicationIds = Set.fromList [appId1, appId2]
               }
           manifest =
-            (emptyManifest fixedTime)
-              { applications = [application1, application2],
-                files = Map.singleton "README.md" fileRecord
-              }
+            ( (emptyManifest fixedTime)
+                & #applications .~ [application1, application2]
+                & #files .~ Map.singleton "README.md" fileRecord
+            )
       manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest
 
     it "rejects malformed baseline references" $ do
@@ -336,8 +552,8 @@
               Nothing
           m1 = writeAppliedBlueprint ab1 m0
           m2 = writeAppliedBlueprint ab2 m1
-      m1.blueprint `shouldBe` Just ab1
-      m2.blueprint `shouldBe` Just ab2
+      (m1 ^. #blueprint) `shouldBe` Just ab1
+      (m2 ^. #blueprint) `shouldBe` Just ab2
 
   describe "AppliedBlueprintMigration" $ do
     it "round-trips a fully populated receipt through JSON" $ do
@@ -353,7 +569,7 @@
 
     it "round-trips a version-5 manifest containing a receipt" $ do
       let receipt = mkBlueprintMigrationReceipt "payments" "1.0.0" "2.0.0" fixedTime
-          manifest = (emptyManifest fixedTime) {blueprintMigrations = [receipt]}
+          manifest = ((emptyManifest fixedTime) & #blueprintMigrations .~ [receipt])
       manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest
 
     it "replaces the same exact edge in place and appends a different edge" $ do
@@ -369,17 +585,17 @@
               (Just "rerun")
           manifest1 = writeAppliedBlueprintMigration unrelated (writeAppliedBlueprintMigration first (emptyManifest fixedTime))
           manifest2 = writeAppliedBlueprintMigration replacement manifest1
-      manifest2.blueprintMigrations `shouldBe` [replacement, unrelated]
+      (manifest2 ^. #blueprintMigrations) `shouldBe` [replacement, unrelated]
       hasAppliedBlueprintMigration "payments" "1.0.0" "2.0.0" manifest2 `shouldBe` True
       hasAppliedBlueprintMigration "payments" "2.0.0" "3.0.0" manifest2 `shouldBe` False
 
     it "preserves modules, applications, files, recipe, and normal blueprint provenance" $ do
-      let appliedModule = AppliedModule "base" emptyParentVars "/installed/base" (Just "1.0.0") fixedTime Nothing
+      let appliedModule = AppliedModule "base" emptyParentVars (LocalOrigin "base") (Just "1.0.0") fixedTime Nothing
           application =
             AppliedComposition
               { applicationId = ApplicationId "app-base",
                 target = AppliedModuleTarget "base",
-                targetSource = "/installed/base",
+                targetOrigin = LocalOrigin "base",
                 targetVersion = Just "1.0.0",
                 additionalModules = [],
                 namespace = Nothing,
@@ -392,98 +608,49 @@
           recipe = AppliedRecipe "recipe" (Just "1.0.0") fixedTime
           normalBlueprint = AppliedBlueprint "payments" (Just "0.4.0") fixedTime [] False Nothing Nothing
           seed =
-            (emptyManifest fixedTime)
-              { modules = [appliedModule],
-                applications = [application],
-                files = Map.singleton "README.md" fileRecord,
-                recipe = Just recipe,
-                blueprint = Just normalBlueprint
-              }
+            ( (emptyManifest fixedTime)
+                & #modules .~ [appliedModule]
+                & #applications .~ [application]
+                & #files .~ Map.singleton "README.md" fileRecord
+                & #recipe .~ Just recipe
+                & #blueprint .~ Just normalBlueprint
+            )
           updated = writeAppliedBlueprintMigration (mkBlueprintMigrationReceipt "payments" "1.0.0" "2.0.0" fixedTime) seed
-      updated.modules `shouldBe` seed.modules
-      updated.applications `shouldBe` seed.applications
-      updated.files `shouldBe` seed.files
-      updated.recipe `shouldBe` seed.recipe
-      updated.blueprint `shouldBe` seed.blueprint
+      (updated ^. #modules) `shouldBe` (seed ^. #modules)
+      (updated ^. #applications) `shouldBe` (seed ^. #applications)
+      (updated ^. #files) `shouldBe` (seed ^. #files)
+      (updated ^. #recipe) `shouldBe` (seed ^. #recipe)
+      (updated ^. #blueprint) `shouldBe` (seed ^. #blueprint)
 
+  -- Schema versions 1 through 5 recorded a machine-specific absolute
+  -- @source@ path in place of the portable @origin@ introduced in version 6.
+  -- Rather than misread them, the decoder refuses them and names the remedy.
+  -- Restoring lossless decoding of those versions is owned by
+  -- docs/plans/79-upgrade-legacy-absolute-path-manifests-in-place.md, which
+  -- also delivers the 'seihou manifest upgrade' command the message names.
   describe "schema back-compat" $ do
-    it "decodes a v4 manifest with no blueprintMigrations key as an empty ledger" $ do
-      let json = "{\"version\":4,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[],\"variables\":{},\"files\":{},\"applications\":[]}"
-      case manifestFromJSON json of
-        Right manifest -> do
-          manifest.version `shouldBe` 4
-          manifest.blueprintMigrations `shouldBe` []
-        Left err -> expectationFailure ("failed to parse v4 manifest: " <> err)
-
-    -- A pre-EP-32 (schema v2) manifest has no @blueprint@ key. The
-    -- decoder must read it as 'Nothing' regardless of the version
-    -- field, so a pre-bump project does not refuse to load after the
-    -- user upgrades seihou.
-    it "decodes a v2 manifest with no blueprint key as Nothing" $ do
-      let json = "{\"version\":2,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[],\"variables\":{},\"files\":{}}"
-      case manifestFromJSON json of
-        Right manifest -> do
-          manifest.blueprint `shouldBe` Nothing
-          manifest.version `shouldBe` 2
-        Left err -> expectationFailure ("failed to parse: " <> err)
-
-    it "decodes a v3 manifest with an explicit null blueprint as Nothing" $ do
-      let json = "{\"version\":3,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[],\"variables\":{},\"files\":{},\"blueprint\":null}"
-      case manifestFromJSON json of
-        Right manifest -> manifest.blueprint `shouldBe` Nothing
-        Left err -> expectationFailure ("failed to parse: " <> err)
-
-    it "decodes a v3 manifest with empty defaults for every version-4 field" $ do
-      let json =
-            "{\"version\":3,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[],\"variables\":{},"
-              <> "\"files\":{\"README.md\":{\"hash\":\"abc\",\"module\":\"legacy\",\"strategy\":\"template\",\"generatedAt\":\"2026-03-01T10:30:00Z\"}}}"
-      case manifestFromJSON json of
-        Right manifest -> do
-          manifest.applications `shouldBe` []
-          case Map.lookup "README.md" manifest.files of
-            Just record -> do
-              record.baseline `shouldBe` Nothing
-              record.applicationIds `shouldBe` Set.empty
-            Nothing -> expectationFailure "expected legacy file record"
-        Left err -> expectationFailure ("failed to parse: " <> err)
-
-    it "decodes a v3 manifest with a populated blueprint object" $ do
-      let json =
-            "{\"version\":3,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[],\"variables\":{},\"files\":{},"
-              <> "\"blueprint\":{\"name\":\"payments-service\",\"version\":\"0.3.1\",\"appliedAt\":\"2026-03-01T11:00:00Z\","
-              <> "\"baselineModules\":[\"nix-flake\"],\"noBaseline\":false,\"userPrompt\":\"set up payments\"}}"
-      case manifestFromJSON json of
-        Right manifest -> case manifest.blueprint of
-          Just ab -> do
-            ab.name `shouldBe` ModuleName "payments-service"
-            ab.blueprintVersion `shouldBe` Just "0.3.1"
-            ab.baselineModules `shouldBe` [ModuleName "nix-flake"]
-            ab.noBaseline `shouldBe` False
-            ab.userPrompt `shouldBe` Just "set up payments"
-            ab.agentSessionId `shouldBe` Nothing
-          Nothing -> expectationFailure "expected populated blueprint"
-        Left err -> expectationFailure ("failed to parse: " <> err)
+    it "refuses every pre-portable-origin schema version and names the remedy" $ do
+      let legacy v =
+            "{\"version\":"
+              <> LBS8.pack (show (v :: Int))
+              <> ",\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[],\"variables\":{},\"files\":{},\"applications\":[]}"
+      forM_ [1 .. 5] $ \v ->
+        case manifestFromJSON (legacy v) of
+          Right _ -> expectationFailure ("schema version " <> show v <> " should not decode directly")
+          Left err -> do
+            err `shouldSatisfy` isInfixOf "seihou manifest upgrade"
+            err `shouldSatisfy` isInfixOf ("schema version " <> show v)
 
-  describe "schema back-compat (version 1)" $ do
-    it "decodes a version-1 manifest with parentVars defaulting to empty" $ do
+    it "refuses a version-1 manifest that records an absolute module source" $ do
       let json = "{\"version\":1,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[{\"name\":\"haskell-base\",\"source\":\"/path\",\"appliedAt\":\"2026-03-01T10:30:00Z\"}],\"variables\":{},\"files\":{}}"
       case manifestFromJSON json of
-        Right manifest -> do
-          length manifest.modules `shouldBe` 1
-          (head manifest.modules).parentVars `shouldBe` emptyParentVars
-        Left err -> expectationFailure ("failed to parse: " <> err)
-
-    it "parses old manifest without version key as Nothing" $ do
-      let json = "{\"version\":1,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[{\"name\":\"old-mod\",\"source\":\"/path\",\"appliedAt\":\"2026-03-01T10:30:00Z\"}],\"variables\":{},\"files\":{}}"
-          result = manifestFromJSON json
-      case result of
-        Right manifest -> (head manifest.modules).moduleVersion `shouldBe` Nothing
-        Left err -> expectationFailure ("failed to parse: " <> err)
+        Right _ -> expectationFailure "a version-1 manifest should not decode directly"
+        Left err -> err `shouldSatisfy` isInfixOf "seihou manifest upgrade"
 
   describe "version checking" $ do
     it "rejects manifests with version higher than current" $ do
       let base = emptyManifest fixedTime
-          m = Manifest {version = 99, genAt = base.genAt, modules = base.modules, vars = base.vars, files = base.files, applications = base.applications, recipe = Nothing, blueprint = Nothing, blueprintMigrations = []}
+          m = Manifest {version = 99, genAt = base ^. #genAt, modules = base ^. #modules, vars = base ^. #vars, files = base ^. #files, applications = base ^. #applications, recipe = Nothing, blueprint = Nothing, blueprintMigrations = []}
           result = manifestFromJSON (manifestToJSON m)
       case result of
         Left err -> err `shouldContain` "newer version"
@@ -493,7 +660,7 @@
     it "produces a hex-encoded SHA256 digest" $ do
       let h = hashContent "hello world"
       -- SHA256 of "hello world" is a well-known value
-      h.unSHA256 `shouldBe` "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
+      (h ^. #unSHA256) `shouldBe` "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
 
     it "produces different hashes for different content" $ do
       let h1 = hashContent "hello"
@@ -512,4 +679,4 @@
     it "handles empty content" $ do
       let h = hashContent ""
       -- SHA256 of empty string is a well-known value
-      h.unSHA256 `shouldBe` "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+      (h ^. #unSHA256) `shouldBe` "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
