packages feed

seihou-core 0.10.1.0 → 0.11.0.0

raw patch · 49 files changed

+1734/−234 lines, 49 filesdep +unixPVP ok

version bump matches the API change (PVP)

Dependencies added: unix

API changes (from Hackage documentation)

- Seihou.Core.Seed: dropSeedOwner :: Set ApplicationId -> FilePath -> Manifest -> Manifest
- Seihou.Core.Seed: seedsOwnedByModule :: ModuleName -> Manifest -> Map FilePath SeedRecord
+ Seihou.Composition.Plan: ComposedPlan :: ![Operation] -> ![CompositionWarning] -> !Map FilePath ModuleName -> !Map FilePath (Set ModuleName) -> ComposedPlan
+ Seihou.Composition.Plan: [managedOwners] :: ComposedPlan -> !Map FilePath ModuleName
+ Seihou.Composition.Plan: [operations] :: ComposedPlan -> ![Operation]
+ Seihou.Composition.Plan: [seedOwners] :: ComposedPlan -> !Map FilePath (Set ModuleName)
+ Seihou.Composition.Plan: [warnings] :: ComposedPlan -> ![CompositionWarning]
+ Seihou.Composition.Plan: data ComposedPlan
+ Seihou.Composition.Plan: instance GHC.Classes.Eq Seihou.Composition.Plan.ComposedPlan
+ Seihou.Composition.Plan: instance GHC.Internal.Generics.Generic Seihou.Composition.Plan.ComposedPlan
+ Seihou.Composition.Plan: instance GHC.Internal.Show.Show Seihou.Composition.Plan.ComposedPlan
+ Seihou.Core.CommandFingerprint: checkoutCommandOperation :: CheckoutCommand -> Operation
+ Seihou.Core.CommandFingerprint: checkoutCommandsFromOperations :: [Operation] -> [CheckoutCommand]
+ Seihou.Core.CommandFingerprint: isCheckoutCommand :: Operation -> Bool
+ Seihou.Core.Path: pathsHaveConflictingSpelling :: FilePath -> FilePath -> Bool
+ Seihou.Core.Seed: InvalidSeedOwners :: FilePath -> Text -> ManifestInvariantError
+ Seihou.Core.Seed: InvalidSeedPath :: FilePath -> Text -> ManifestInvariantError
+ Seihou.Core.Seed: SeedClaimsOutsideSelection :: FilePath -> Set ApplicationId -> SeedOwnershipError
+ Seihou.Core.Seed: SeedOwnershipUnresolved :: FilePath -> Set ApplicationId -> SeedOwnershipError
+ Seihou.Core.Seed: SeedPathOverlap :: FilePath -> FilePath -> ManifestInvariantError
+ Seihou.Core.Seed: data SeedOwnershipError
+ Seihou.Core.Seed: dropSeedOwners :: Map ApplicationId (Set ModuleName) -> FilePath -> Manifest -> Either SeedOwnershipError Manifest
+ Seihou.Core.Seed: instance GHC.Classes.Eq Seihou.Core.Seed.SeedOwnershipError
+ Seihou.Core.Seed: instance GHC.Internal.Generics.Generic Seihou.Core.Seed.SeedOwnershipError
+ Seihou.Core.Seed: instance GHC.Internal.Show.Show Seihou.Core.Seed.SeedOwnershipError
+ Seihou.Core.Seed: renderManifestInvariantError :: ManifestInvariantError -> Text
+ Seihou.Core.Seed: renderSeedOwnershipError :: SeedOwnershipError -> Text
+ Seihou.Core.Seed: replaceSelectedSeedClaims :: Set ApplicationId -> Map FilePath SeedRecord -> Manifest -> Either SeedOwnershipError Manifest
+ Seihou.Core.Seed: seedApplicationIds :: SeedRecord -> Set ApplicationId
+ Seihou.Core.Seed: seedClaimsFor :: ApplicationId -> ModuleName -> Manifest -> Either SeedOwnershipError (Map FilePath SeedRecord)
+ Seihou.Core.Types: ApplicationCommand :: CommandLifecycle
+ Seihou.Core.Types: CheckoutCommand :: !ModuleName -> !Int -> !Text -> !Maybe FilePath -> CheckoutCommand
+ Seihou.Core.Types: CheckoutCommandLifecycle :: CommandLifecycle
+ Seihou.Core.Types: CheckoutLocalCommands :: ManifestCapability
+ Seihou.Core.Types: KnownSeedOwners :: !Map ApplicationId (Set ModuleName) -> SeedOwnership
+ Seihou.Core.Types: LegacySeedOwners :: !Set ApplicationId -> SeedOwnership
+ Seihou.Core.Types: SeedContentIgnored :: FilePath -> ModuleName -> ModuleName -> CompositionWarning
+ Seihou.Core.Types: SeedFileOp :: !FilePath -> !Text -> !Strategy -> !ModuleName -> Operation
+ Seihou.Core.Types: SeedOwnerClaims :: ManifestCapability
+ Seihou.Core.Types: [checkoutCommands] :: AppliedComposition -> ![CheckoutCommand]
+ Seihou.Core.Types: [ownership] :: SeedRecord -> !SeedOwnership
+ Seihou.Core.Types: data CheckoutCommand
+ Seihou.Core.Types: data CommandLifecycle
+ Seihou.Core.Types: data SeedOwnership
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.CheckoutCommand
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.CommandLifecycle
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.SeedOwnership
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.CommandLifecycle
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.CheckoutCommand
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.CommandLifecycle
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.SeedOwnership
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.CheckoutCommand
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.CommandLifecycle
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.SeedOwnership
+ Seihou.Effect.Filesystem: ExclusiveCreated :: ExclusiveCreateResult
+ Seihou.Effect.Filesystem: ExclusiveFailure :: Text -> ExclusiveCreateResult
+ Seihou.Effect.Filesystem: ExclusiveOccupied :: ExclusiveCreateResult
+ Seihou.Effect.Filesystem: [CreateFileExclusive] :: forall (a :: Type -> Type). FilePath -> Text -> Filesystem a ExclusiveCreateResult
+ Seihou.Effect.Filesystem: createFileExclusive :: forall (es :: [Effect]). Filesystem :> es => FilePath -> Text -> Eff es ExclusiveCreateResult
+ Seihou.Effect.Filesystem: data ExclusiveCreateResult
+ Seihou.Effect.FilesystemInterp: createFileExclusiveWith :: (Handle -> IO ()) -> FilePath -> IO ExclusiveCreateResult
+ Seihou.Engine.Migrate: validateSeedMigration :: Set FilePath -> [MigrationOp] -> Either MigrationExecError ()
+ Seihou.Engine.Reconcile: AdoptingMissingSeedFile :: ReconciliationReason
+ Seihou.Engine.Reconcile: AdoptingSeedFile :: ReconciliationReason
+ Seihou.Engine.Reconcile: FileSeedAbandon :: FilePath -> SeedRecord -> FileReconciliation
+ Seihou.Engine.Reconcile: FileSeedCreate :: SeedAction -> PathEntry -> FileReconciliation
+ Seihou.Engine.Reconcile: FileSeedRelease :: SeedAction -> FileRecord -> FileReconciliation
+ Seihou.Engine.Reconcile: FileSeedSettle :: SeedAction -> FileReconciliation
+ Seihou.Engine.Reconcile: InvalidSeedAdoptionChoice :: FilePath -> ReconciliationError
+ Seihou.Engine.Reconcile: SeedAction :: !FilePath -> !ModuleName -> !Map ApplicationId (Set ModuleName) -> !SeedDecision -> !Maybe Text -> SeedAction
+ Seihou.Engine.Reconcile: SeedPathAlsoManaged :: FilePath -> ReconciliationError
+ Seihou.Engine.Reconcile: SeedReconciliationFailed :: Text -> ReconciliationError
+ Seihou.Engine.Reconcile: SeedReleaseBlockedByOwners :: FilePath -> Set ApplicationId -> ReconciliationError
+ Seihou.Engine.Reconcile: [claims] :: SeedAction -> !Map ApplicationId (Set ModuleName)
+ Seihou.Engine.Reconcile: [decision] :: SeedAction -> !SeedDecision
+ Seihou.Engine.Reconcile: [newContent] :: SeedAction -> !Maybe Text
+ Seihou.Engine.Reconcile: [supplier] :: SeedAction -> !ModuleName
+ Seihou.Engine.Reconcile: data SeedAction
+ Seihou.Engine.Reconcile: instance GHC.Classes.Eq Seihou.Engine.Reconcile.SeedAction
+ Seihou.Engine.Reconcile: instance GHC.Internal.Generics.Generic Seihou.Engine.Reconcile.SeedAction
+ Seihou.Engine.Reconcile: instance GHC.Internal.Show.Show Seihou.Engine.Reconcile.SeedAction
+ Seihou.Engine.Reconcile: planSeedReconciliation :: forall (es :: [Effect]). (Filesystem :> es, BaselineStore :> es, IOE :> es) => FilePath -> FilePath -> Manifest -> Set ApplicationId -> [Operation] -> Map FilePath DesiredFileOwner -> Map FilePath (Map ApplicationId (Set ModuleName)) -> Eff es (Either ReconciliationError ReconciliationPlan)
+ Seihou.Engine.Reconcile: seedCandidateReceipts :: UTCTime -> Manifest -> ReconciliationPlan -> Map FilePath SeedRecord
+ Seihou.Engine.Remove: KeepSeedOp :: FilePath -> RemovalOp
+ Seihou.Engine.Remove: selectRecordedRemoval :: Manifest -> ModuleName -> Maybe ApplicationId -> Either CheckoutError (Maybe CheckoutRemovalSelection)
+ Seihou.Engine.Seed: PlannedSeed :: !FilePath -> !Text -> !Strategy -> !ModuleName -> !Set ModuleName -> !PathEntry -> !SeedDecision -> PlannedSeed
+ Seihou.Engine.Seed: SeedAlreadySettled :: SeedDecision
+ Seihou.Engine.Seed: SeedError :: !Text -> !Text -> SeedError
+ Seihou.Engine.Seed: SeedKeepExisting :: SeedDecision
+ Seihou.Engine.Seed: SeedReleaseManaged :: SeedDecision
+ Seihou.Engine.Seed: SeedRespectDeletion :: SeedDecision
+ Seihou.Engine.Seed: SeedWrite :: SeedDecision
+ Seihou.Engine.Seed: [code] :: SeedError -> !Text
+ Seihou.Engine.Seed: [content] :: PlannedSeed -> !Text
+ Seihou.Engine.Seed: [decision] :: PlannedSeed -> !SeedDecision
+ Seihou.Engine.Seed: [dest] :: PlannedSeed -> !FilePath
+ Seihou.Engine.Seed: [message] :: SeedError -> !Text
+ Seihou.Engine.Seed: [observed] :: PlannedSeed -> !PathEntry
+ Seihou.Engine.Seed: [owners] :: PlannedSeed -> !Set ModuleName
+ Seihou.Engine.Seed: [strategy] :: PlannedSeed -> !Strategy
+ Seihou.Engine.Seed: [supplier] :: PlannedSeed -> !ModuleName
+ Seihou.Engine.Seed: classifySeed :: Maybe SeedRecord -> Maybe FileRecord -> PathEntry -> Either SeedError SeedDecision
+ Seihou.Engine.Seed: data PlannedSeed
+ Seihou.Engine.Seed: data SeedDecision
+ Seihou.Engine.Seed: data SeedError
+ Seihou.Engine.Seed: executeSeeds :: forall (es :: [Effect]). Filesystem :> es => FilePath -> ApplicationId -> UTCTime -> [PlannedSeed] -> Manifest -> Eff es (Either SeedError (Map FilePath SeedRecord))
+ Seihou.Engine.Seed: instance GHC.Classes.Eq Seihou.Engine.Seed.PlannedSeed
+ Seihou.Engine.Seed: instance GHC.Classes.Eq Seihou.Engine.Seed.SeedDecision
+ Seihou.Engine.Seed: instance GHC.Classes.Eq Seihou.Engine.Seed.SeedError
+ Seihou.Engine.Seed: instance GHC.Internal.Generics.Generic Seihou.Engine.Seed.PlannedSeed
+ Seihou.Engine.Seed: instance GHC.Internal.Generics.Generic Seihou.Engine.Seed.SeedDecision
+ Seihou.Engine.Seed: instance GHC.Internal.Generics.Generic Seihou.Engine.Seed.SeedError
+ Seihou.Engine.Seed: instance GHC.Internal.Show.Show Seihou.Engine.Seed.PlannedSeed
+ Seihou.Engine.Seed: instance GHC.Internal.Show.Show Seihou.Engine.Seed.SeedDecision
+ Seihou.Engine.Seed: instance GHC.Internal.Show.Show Seihou.Engine.Seed.SeedError
+ Seihou.Engine.Seed: planSeeds :: forall (es :: [Effect]). Filesystem :> es => FilePath -> Manifest -> Map FilePath (Set ModuleName) -> [Operation] -> Eff es (Either [SeedError] [PlannedSeed])
+ Seihou.Engine.Seed: renderSeedPlan :: Bool -> [PlannedSeed] -> Text
+ Seihou.Engine.Seed: seedOperationClaims :: [Operation] -> Map FilePath (Set ModuleName)
+ Seihou.Engine.Seed: validateSeedRun :: Manifest -> [Operation] -> Either [SeedError] ()
+ Seihou.Engine.UpdateTransaction: beginSeedUpdateTransaction :: FilePath -> Set FilePath -> Set FilePath -> IO (Either TransactionError UpdateTransaction)
+ Seihou.Filesystem.Exclusive: ExclusiveCreated :: ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: ExclusiveFailure :: Text -> ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: ExclusiveOccupied :: ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: createFileExclusiveAnchored :: Maybe FilePath -> (Handle -> IO ()) -> FilePath -> IO (ExclusiveCreateResult, Maybe (Integer, Integer))
+ Seihou.Filesystem.Exclusive: createFileExclusiveWith :: (Handle -> IO ()) -> FilePath -> IO ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: createFileExclusiveWithIdentity :: (Handle -> IO ()) -> FilePath -> IO (ExclusiveCreateResult, Maybe (Integer, Integer))
+ Seihou.Filesystem.Exclusive: data ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: instance GHC.Classes.Eq Seihou.Filesystem.Exclusive.ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: instance GHC.Internal.Generics.Generic Seihou.Filesystem.Exclusive.ExclusiveCreateResult
+ Seihou.Filesystem.Exclusive: instance GHC.Internal.Show.Show Seihou.Filesystem.Exclusive.ExclusiveCreateResult
+ Seihou.Manifest.Upgrade: AddEmptyCheckoutCommands :: UpgradeStepAction
+ Seihou.Manifest.Upgrade: MakeSeedOwnershipExplicit :: UpgradeStepAction
- Seihou.Composition.Plan: compileComposedPlan :: [(ModuleInstance, Module, FilePath, Map VarName VarValue)] -> IO (Either [Text] ([Operation], [CompositionWarning], Map FilePath ModuleName))
+ Seihou.Composition.Plan: compileComposedPlan :: [(ModuleInstance, Module, FilePath, Map VarName VarValue)] -> IO (Either [Text] ComposedPlan)
- Seihou.Composition.Plan: mergeOperations :: [(ModuleName, [Operation])] -> ([Operation], [CompositionWarning], Map FilePath ModuleName)
+ Seihou.Composition.Plan: mergeOperations :: [(ModuleName, [Operation])] -> Either [Text] ComposedPlan
- Seihou.Core.Seed: mergeSeedRecord :: FilePath -> SeedRecord -> Manifest -> Manifest
+ Seihou.Core.Seed: mergeSeedRecord :: FilePath -> SeedRecord -> Manifest -> Either SeedOwnershipError Manifest
- Seihou.Core.Types: AppliedComposition :: !ApplicationId -> !AppliedTarget -> !ArtifactOrigin -> !Maybe Text -> ![ModuleName] -> !Maybe Text -> !Maybe Text -> ![AppliedInstanceState] -> !Map CommandFingerprint CommandReceipt -> !UTCTime -> AppliedComposition
+ Seihou.Core.Types: AppliedComposition :: !ApplicationId -> !AppliedTarget -> !ArtifactOrigin -> !Maybe Text -> ![ModuleName] -> !Maybe Text -> !Maybe Text -> ![AppliedInstanceState] -> !Map CommandFingerprint CommandReceipt -> ![CheckoutCommand] -> !UTCTime -> AppliedComposition
- Seihou.Core.Types: Command :: !Text -> !Maybe Text -> !Maybe Expr -> Command
+ Seihou.Core.Types: Command :: !Text -> !Maybe Text -> !Maybe Expr -> !CommandLifecycle -> Command
- Seihou.Core.Types: RunCommandOp :: !Text -> !Maybe FilePath -> !ModuleName -> !Int -> Operation
+ Seihou.Core.Types: RunCommandOp :: !Text -> !Maybe FilePath -> !ModuleName -> !Int -> !CommandLifecycle -> Operation
- Seihou.Core.Types: SeedRecord :: !ModuleName -> !Set ApplicationId -> !SeedOutcome -> !UTCTime -> SeedRecord
+ Seihou.Core.Types: SeedRecord :: !ModuleName -> !SeedOwnership -> !SeedOutcome -> !UTCTime -> SeedRecord
- Seihou.Core.Types: [lifecycle] :: Step -> !StepLifecycle
+ Seihou.Core.Types: [lifecycle] :: Operation -> !CommandLifecycle
- Seihou.Engine.Reconcile: [path] :: DesiredFile -> !FilePath
+ Seihou.Engine.Reconcile: [path] :: SeedAction -> !FilePath

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.10.1.0+version: 0.11.0.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.@@ -16,6 +16,7 @@ copyright: (c) 2026 Nadeem Bitar category: Development build-type: Simple+extra-source-files: test/native/SeedExclusive.hs  source-repository head   type: git@@ -102,11 +103,13 @@     Seihou.Engine.Reconcile     Seihou.Engine.Remove     Seihou.Engine.Section+    Seihou.Engine.Seed     Seihou.Engine.Template     Seihou.Engine.ThreeWayMerge     Seihou.Engine.TypedDhallText     Seihou.Engine.UpdateTransaction     Seihou.Engine.Validate+    Seihou.Filesystem.Exclusive     Seihou.Interaction.Confirm     Seihou.Interaction.Prompt     Seihou.Manifest.Hash@@ -135,6 +138,7 @@     text >=2.0 && <3,     time >=1.12 && <2,     transformers >=0.6 && <1,+    unix >=2.8 && <2.9,     yaml >=0.11 && <1,  test-suite seihou-core-test@@ -202,6 +206,8 @@     Seihou.Engine.ReconcileSpec     Seihou.Engine.RemoveSpec     Seihou.Engine.SectionSpec+    Seihou.Engine.SeedSpec+    Seihou.Engine.SeedTransactionSpec     Seihou.Engine.TemplateSpec     Seihou.Engine.ThreeWayMergeSpec     Seihou.Engine.UpdateTransactionSpec
src/Seihou/Composition/Plan.hs view
@@ -1,11 +1,13 @@ module Seihou.Composition.Plan-  ( compileComposedPlan,+  ( ComposedPlan (..),+    compileComposedPlan,     mergeOperations,     mergeStructuredContent,     deepMergeJSON,   ) where +import Control.Monad (forM_) import Data.Aeson qualified as Aeson import Data.Aeson.Encode.Pretty qualified as AesonPretty import Data.Aeson.KeyMap qualified as KM@@ -19,12 +21,21 @@ import Data.Yaml qualified as Yaml import Seihou.Composition.Instance (ModuleInstance, qualifiedName) import Seihou.Core.Checkout (CheckoutError (..), checkoutClaims, pathsOverlap)+import Seihou.Core.Path (pathsHaveConflictingSpelling, validateCanonicalProjectPath) import Seihou.Core.Types import Seihou.Engine.Plan (compilePlan) import Seihou.Engine.Section (applyTextPatch) import Seihou.Prelude import System.FilePath (takeExtension) +data ComposedPlan = ComposedPlan+  { operations :: ![Operation],+    warnings :: ![CompositionWarning],+    managedOwners :: !(Map FilePath ModuleName),+    seedOwners :: !(Map FilePath (Set ModuleName))+  }+  deriving stock (Eq, Show, Generic)+ -- | Compile plans for all modules in execution order and merge into a -- single operation list. File conflicts are resolved by last-writer-wins -- with 'CompositionWarning' entries for overwritten files.@@ -36,7 +47,7 @@ -- collide on file ownership. compileComposedPlan ::   [(ModuleInstance, Module, FilePath, Map VarName VarValue)] ->-  IO (Either [Text] ([Operation], [CompositionWarning], Map FilePath ModuleName))+  IO (Either [Text] ComposedPlan) compileComposedPlan modules = do   results <- mapM compileOne modules   let (errs, opsPerModule) = partitionResults results@@ -49,7 +60,7 @@       pure $ case checkoutClaims (ApplicationId "composition") ops of         Left err -> Left [err ^. #message]         Right _ | not (null overlaps) -> Left overlaps-        Right _ -> Right (mergeOperations opsPerModule)+        Right _ -> mergeOperations opsPerModule     else pure (Left (concat errs))   where     compileOne (inst, m, dir, vars) = do@@ -81,10 +92,40 @@ -- -- 'CreateDirOp' operations are silently deduplicated. -- 'RunCommandOp' operations are always included.-mergeOperations ::+mergeOperations :: [(ModuleName, [Operation])] -> Either [Text] ComposedPlan+mergeOperations moduleOps = do+  let allOps = concatMap snd moduleOps+      seeds = [op | op@SeedFileOp {} <- allOps]+      claims = Map.fromListWith Set.union [(dest, Set.singleton moduleName) | SeedFileOp {dest, moduleName} <- seeds]+      destinations =+        [(dest, False) | op <- allOps, Just dest <- [destOfOp op]]+          <> [(dest, True) | SeedFileOp {dest} <- seeds]+          <> [(dest, False) | CheckoutOutputOp {dest} <- allOps]+      collisions = ["seed '" <> T.pack path <> "' overlaps a different lifecycle or file destination '" <> T.pack other <> "'" | SeedFileOp {dest = path} <- seeds, (other, isSeed) <- destinations, (pathsOverlap path other || pathsHaveConflictingSpelling path other), not (isSeed && path == other)]+  forM_ seeds $ \SeedFileOp {dest} -> first pure (validateCanonicalProjectPath (T.pack dest))+  if null seeds then Right () else forM_ destinations (\(dest, _) -> first pure (validateCanonicalProjectPath (T.pack dest)))+  if not (null collisions)+    then Left collisions+    else do+      let (ops, warnings, owners) = mergeManagedOperations [(name, filter (\case SeedFileOp {} -> False; _ -> True) operations) | (name, operations) <- moduleOps]+          (selected, seedWarnings) = chooseSeeds Set.empty Map.empty seeds+      Right (ComposedPlan (ops <> selected) (warnings <> seedWarnings) owners claims)+  where+    chooseSeeds _ _ [] = ([], [])+    chooseSeeds seen suppliers (op@SeedFileOp {dest, content, moduleName} : rest)+      | Set.member dest seen =+          let (ops, warnings) = chooseSeeds seen suppliers rest+              extra = case Map.lookup dest suppliers of+                Just (firstContent, supplier) | firstContent /= content -> [SeedContentIgnored dest supplier moduleName]+                _ -> []+           in (ops, extra <> warnings)+      | otherwise = let (ops, warnings) = chooseSeeds (Set.insert dest seen) (Map.insert dest (content, moduleName) suppliers) rest in (op : ops, warnings)+    chooseSeeds seen suppliers (_ : rest) = chooseSeeds seen suppliers rest++mergeManagedOperations ::   [(ModuleName, [Operation])] ->   ([Operation], [CompositionWarning], Map FilePath ModuleName)-mergeOperations moduleOps =+mergeManagedOperations moduleOps =   let tagged = [(name, op) | (name, ops) <- moduleOps, op <- ops]       (result, warnings, owners) = go tagged Map.empty Set.empty [] []    in (reverse result, warnings, owners)@@ -92,6 +133,7 @@     go [] fileOwner _ opsAcc warningsAcc = (opsAcc, warningsAcc, fileOwner)     go ((name, op) : rest) fileOwner seenDirs opsAcc warningsAcc =       case op of+        SeedFileOp {} -> go rest fileOwner seenDirs opsAcc warningsAcc         CheckoutOutputOp {} -> go rest fileOwner seenDirs (op : opsAcc) warningsAcc         CreateDirOp p           | Set.member p seenDirs -> go rest fileOwner seenDirs opsAcc warningsAcc
src/Seihou/Composition/Resolve.hs view
@@ -50,7 +50,7 @@ loadComposition searchPaths primary additional = runExceptT $ do   (primaryMod, primaryDir) <- ExceptT $ loadModuleWithDir searchPaths primary   let effectiveDeps = primaryMod ^. #dependencies ++ map simpleDep additional-      effectivePrimary = (primaryMod & #dependencies .~ nubOrdBy (^. #module_) effectiveDeps)+      effectivePrimary = (primaryMod & #dependencies .~ nubOrdBy (\dep -> (dep ^. #module_, parentVarsFromDep dep)) effectiveDeps)       primaryInst = primaryInstance primary       loaded = Map.singleton primaryInst (effectivePrimary, primaryDir)       seeds = [(mkInstance (dep ^. #module_) (parentVarsFromDep dep)) | dep <- effectivePrimary ^. #dependencies]
src/Seihou/Core/Application.hs view
@@ -65,6 +65,7 @@       context = context,       instances = map buildInstance modulesInOrder,       commandReceipts = Map.empty,+      checkoutCommands = [],       appliedAt = now     }   where
src/Seihou/Core/ArtifactIdentity.hs view
@@ -57,11 +57,16 @@ -- @https:\/\/host\/repo@, @https:\/\/host\/repo.git@ and -- @https:\/\/host\/repo\/@ all name the same repository, and a manifest -- written by a developer who typed one of them must not read as a different--- artifact to a developer who typed another.+-- artifact to a developer who typed another. Public GitHub SSH spellings also+-- normalize to HTTPS; no equivalence is inferred for other hosts or paths. normalizeOriginUrl :: Text -> Text normalizeOriginUrl =-  dropTrailingSlashes . dropGitSuffix . dropTrailingSlashes . T.strip+  dropTrailingSlashes . dropGitSuffix . dropTrailingSlashes . githubTransport . T.strip   where+    githubTransport url+      | Just path <- T.stripPrefix "git@github.com:" url = "https://github.com/" <> path+      | Just path <- T.stripPrefix "ssh://git@github.com/" url = "https://github.com/" <> path+      | otherwise = url     dropTrailingSlashes = T.dropWhileEnd (== '/')     dropGitSuffix url = fromMaybe url (T.stripSuffix ".git" url) 
src/Seihou/Core/CommandFingerprint.hs view
@@ -1,5 +1,8 @@ module Seihou.Core.CommandFingerprint   ( fingerprintCommand,+    checkoutCommandsFromOperations,+    checkoutCommandOperation,+    isCheckoutCommand,   ) where @@ -23,3 +26,18 @@         "occurrence=" <> T.pack (show occurrence)       ] fingerprintCommand _ = Nothing++-- | Capture declarations from the complete compiled plan, even when execution is disabled.+checkoutCommandsFromOperations :: [Operation] -> [CheckoutCommand]+checkoutCommandsFromOperations operations =+  [ CheckoutCommand moduleName occurrence command workDir+  | RunCommandOp {moduleName, occurrence, command, workDir, lifecycle = CheckoutCommandLifecycle} <- operations+  ]++checkoutCommandOperation :: CheckoutCommand -> Operation+checkoutCommandOperation CheckoutCommand {moduleName, occurrence, command, workDir} =+  RunCommandOp {moduleName, occurrence, command, workDir, lifecycle = CheckoutCommandLifecycle}++isCheckoutCommand :: Operation -> Bool+isCheckoutCommand RunCommandOp {lifecycle = CheckoutCommandLifecycle} = True+isCheckoutCommand _ = False
src/Seihou/Core/Module.hs view
@@ -316,7 +316,7 @@  -- 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) <> ["Removal.commands cannot declare checkout setup" | removal <- maybeToList (m ^. #removal), command <- removal ^. #commands, command ^. #lifecycle == CheckoutCommandLifecycle]   where     checkCmd c = checkEmptyRun c <> checkWorkDir (c ^. #workDir) @@ -580,7 +580,7 @@     seedSteps = filter ((== Seed) . (^. #lifecycle)) (m ^. #steps)     checkStep s =       ["step writing '" <> s ^. #dest <> "' is a seed but has a patch; a seed creates a whole file once and cannot patch one" | isJust (s ^. #patch)]-        <> ["step writing '" <> s ^. #dest <> "' is a seed but uses the structured strategy; seeds support copy, template, and dhall-text" | s ^. #strategy == Structured]+        <> ["step writing '" <> s ^. #dest <> "' is a seed but uses an unsupported strategy; seeds support copy, template, and dhall-text" | s ^. #strategy `notElem` [Copy, Template, DhallText]]     mixed =       [ "steps writing '" <> dest <> "' mix the seed and managed lifecycles; use one lifecycle per destination"       | dest <- Set.toAscList (Set.fromList (map (^. #dest) seedSteps)),@@ -590,7 +590,6 @@       [ "removal step deletes '" <> s ^. #dest <> "', which a seed step creates; seihou never deletes seed files"       | removal <- maybeToList (m ^. #removal),         r <- removal ^. #steps,-        r ^. #action == RemoveFileAction,         s <- seedSteps,         s ^. #dest == r ^. #dest       ]
src/Seihou/Core/Path.hs view
@@ -1,6 +1,7 @@ module Seihou.Core.Path   ( validateProjectRelativePath,     validateCanonicalProjectPath,+    pathsHaveConflictingSpelling,   ) where @@ -40,3 +41,13 @@     || any (\part -> T.null part || part == "." || part == ".." || T.toCaseFold part `elem` [".git", ".seihou"]) (T.splitOn "/" raw)     then Left ("path must use canonical project-relative components outside .git and .seihou: " <> raw)     else Right ()++-- | Refuse case aliases in the portable output namespace, including conflicting+-- ancestor spellings, before any filesystem object is created.+pathsHaveConflictingSpelling :: FilePath -> FilePath -> Bool+pathsHaveConflictingSpelling a b = go (T.splitOn "/" (T.pack a)) (T.splitOn "/" (T.pack b))+  where+    go (x : xs) (y : ys)+      | x == y = go xs ys+      | otherwise = T.toCaseFold x == T.toCaseFold y+    go _ _ = False
src/Seihou/Core/Seed.hs view
@@ -1,39 +1,143 @@ module Seihou.Core.Seed-  ( mergeSeedRecord,-    dropSeedOwner,-    seedsOwnedByModule,+  ( seedApplicationIds,+    mergeSeedRecord,+    dropSeedOwners,+    seedClaimsFor,+    replaceSelectedSeedClaims,+    SeedOwnershipError (..),+    renderSeedOwnershipError,     ManifestInvariantError (..),+    renderManifestInvariantError,     validateManifestInvariants,   ) where +import Control.Monad (forM_, unless, when) import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Set qualified as Set+import Data.Text qualified as T+import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName)+import Seihou.Core.Checkout (pathsOverlap)+import Seihou.Core.Path (pathsHaveConflictingSpelling, validateCanonicalProjectPath) import Seihou.Core.Types import Seihou.Prelude --- | Keep the first settlement; callers must check the final manifest's invariants.--- Never create a receipt for a managed path.-mergeSeedRecord :: FilePath -> SeedRecord -> Manifest -> Manifest-mergeSeedRecord path receipt = #seeds %~ Map.insertWith merge path receipt+seedApplicationIds :: SeedRecord -> Set ApplicationId+seedApplicationIds record = case record ^. #ownership of+  LegacySeedOwners apps -> apps+  KnownSeedOwners owners -> Map.keysSet owners++data SeedOwnershipError+  = SeedOwnershipUnresolved FilePath (Set ApplicationId)+  | SeedClaimsOutsideSelection FilePath (Set ApplicationId)+  deriving stock (Eq, Show, Generic)++renderSeedOwnershipError :: SeedOwnershipError -> Text+renderSeedOwnershipError = \case+  SeedOwnershipUnresolved path apps ->+    "seed '"+      <> T.pack path+      <> "' needs exact recorded module evidence for applications "+      <> T.intercalate ", " (map (^. #unApplicationId) (Set.toAscList apps))+      <> "; restore access to their recorded releases and retry the command"+  SeedClaimsOutsideSelection path _ -> "seed claims for '" <> T.pack path <> "' include an unselected application"++-- | Union claims, retaining the original supplier, outcome and time. Callers+-- validate the combined manifest after attaching its recorded applications.+mergeSeedRecord :: FilePath -> SeedRecord -> Manifest -> Either SeedOwnershipError Manifest+mergeSeedRecord path receipt manifest = do+  combined <- case Map.lookup path (manifest ^. #seeds) of+    Nothing -> Right receipt+    Just old -> do+      owners <- case (old ^. #ownership, receipt ^. #ownership) of+        (KnownSeedOwners a, KnownSeedOwners b) -> Right (KnownSeedOwners (Map.unionWith Set.union a b))+        (LegacySeedOwners a, LegacySeedOwners b) -> Right (LegacySeedOwners (a <> b))+        _ -> Left (SeedOwnershipUnresolved path (seedApplicationIds old <> seedApplicationIds receipt))+      Right (old & #ownership .~ owners)+  pure (manifest & #seeds %~ Map.insert path combined)++dropSeedOwners :: Map ApplicationId (Set ModuleName) -> FilePath -> Manifest -> Either SeedOwnershipError Manifest+dropSeedOwners departing path manifest = case Map.lookup path (manifest ^. #seeds) of+  Nothing -> Right manifest+  Just receipt -> do+    retained <- case receipt ^. #ownership of+      LegacySeedOwners apps+        | Set.null (apps `Set.intersection` Map.keysSet departing) -> Right (Just receipt)+        | otherwise -> Left (SeedOwnershipUnresolved path apps)+      KnownSeedOwners owners ->+        let rest = Map.mapMaybeWithKey (\app names -> nonempty (names `Set.difference` Map.findWithDefault Set.empty app departing)) owners+         in Right (if Map.null rest then Nothing else Just (receipt & #ownership .~ KnownSeedOwners rest))+    pure (manifest & #seeds %~ Map.update (const retained) path)++seedClaimsFor :: ApplicationId -> ModuleName -> Manifest -> Either SeedOwnershipError (Map FilePath SeedRecord)+seedClaimsFor app name manifest = Map.traverseMaybeWithKey select (manifest ^. #seeds)   where-    merge new old = old & #applicationIds %~ Set.union (new ^. #applicationIds)+    select path receipt = case receipt ^. #ownership of+      LegacySeedOwners apps+        | Set.member app apps -> Left (SeedOwnershipUnresolved path apps)+        | otherwise -> Right Nothing+      KnownSeedOwners owners -> Right (if Set.member name (Map.findWithDefault Set.empty app owners) then Just receipt else Nothing) -dropSeedOwner :: Set ApplicationId -> FilePath -> Manifest -> Manifest-dropSeedOwner departing path = #seeds %~ Map.update dropOwners path+-- | Replace the complete claim set of selected applications in one pass.+-- A surviving receipt keeps its original settlement even when every old+-- selected claim was replaced. Unknown unselected claims are preserved.+replaceSelectedSeedClaims :: Set ApplicationId -> Map FilePath SeedRecord -> Manifest -> Either SeedOwnershipError Manifest+replaceSelectedSeedClaims selected incoming manifest = do+  forM_ (Map.toAscList incoming) $ \(path, receipt) -> do+    unless (seedApplicationIds receipt `Set.isSubsetOf` selected) $+      Left (SeedClaimsOutsideSelection path (seedApplicationIds receipt Set.\\ selected))+    case receipt ^. #ownership of+      LegacySeedOwners apps -> Left (SeedOwnershipUnresolved path apps)+      KnownSeedOwners _ -> Right ()+  records <- Map.traverseMaybeWithKey replace (Map.union (manifest ^. #seeds) incoming)+  pure (manifest & #seeds .~ records)   where-    dropOwners record =-      let retained = (record ^. #applicationIds) `Set.difference` departing-       in if Set.null retained then Nothing else Just (record & #applicationIds .~ retained)+    replace path _ = case Map.lookup path (manifest ^. #seeds) of+      Nothing -> Right (Map.lookup path incoming)+      Just old -> case old ^. #ownership of+        LegacySeedOwners apps+          | not (Set.null (apps `Set.intersection` selected)) || Map.member path incoming -> Left (SeedOwnershipUnresolved path apps)+          | otherwise -> Right (Just old)+        KnownSeedOwners owners ->+          let proposed = case (^. #ownership) <$> Map.lookup path incoming of+                Just (KnownSeedOwners claims) -> claims+                _ -> Map.empty+              rest = Map.unionWith Set.union (Map.withoutKeys owners selected) proposed+           in Right (if Map.null rest then Nothing else Just (old & #ownership .~ KnownSeedOwners rest)) -seedsOwnedByModule :: ModuleName -> Manifest -> Map FilePath SeedRecord-seedsOwnedByModule name manifest = Map.filter ((== name) . (^. #moduleName)) (manifest ^. #seeds)+nonempty :: Set a -> Maybe (Set a)+nonempty names = if Set.null names then Nothing else Just names -data ManifestInvariantError = PathBothManagedAndSeeded FilePath+data ManifestInvariantError+  = PathBothManagedAndSeeded FilePath+  | InvalidSeedPath FilePath Text+  | SeedPathOverlap FilePath FilePath+  | InvalidSeedOwners FilePath Text   deriving stock (Eq, Show, Generic) +renderManifestInvariantError :: ManifestInvariantError -> Text+renderManifestInvariantError = \case+  PathBothManagedAndSeeded path -> "path " <> T.pack path <> " is recorded both as a tracked file and as a seed"+  InvalidSeedPath path reason -> "seed '" <> T.pack path <> "': " <> reason+  SeedPathOverlap path other -> "seed '" <> T.pack path <> "' overlaps output '" <> T.pack other <> "'"+  InvalidSeedOwners path reason -> "seed '" <> T.pack path <> "' " <> reason+ validateManifestInvariants :: Manifest -> Either ManifestInvariantError ()-validateManifestInvariants manifest = case Map.keys (Map.intersection (manifest ^. #files) (manifest ^. #seeds)) of-  path : _ -> Left (PathBothManagedAndSeeded path)-  [] -> Right ()+validateManifestInvariants manifest = do+  forM_ (Map.keys (Map.intersection (manifest ^. #files) (manifest ^. #seeds))) $ \path -> Left (PathBothManagedAndSeeded path)+  forM_ (Map.toAscList (manifest ^. #seeds)) $ \(path, receipt) -> case receipt ^. #ownership of+    -- Preserve historical receipts without pretending they contain exact facts.+    LegacySeedOwners _ -> Right ()+    KnownSeedOwners owners -> do+      first (InvalidSeedPath path) (validateCanonicalProjectPath (T.pack path))+      when (Map.null owners) $ bad path "has no owners"+      forM_ (Map.toAscList owners) $ \(appId, names) -> do+        when (Set.null names) $ bad path "has an empty instance owner set"+        case filter ((== appId) . (^. #applicationId)) (manifest ^. #applications) of+          [app] -> unless (names `Set.isSubsetOf` Set.fromList [qualifiedName (ModuleInstance (state ^. #name) (state ^. #parentVars)) | state <- app ^. #instances]) $ bad path "names an instance outside its owning application"+          _ -> bad path "names an unknown or duplicate application owner"+      forM_ (Map.keys (manifest ^. #files) <> Map.keys (manifest ^. #checkoutOutputs) <> filter (/= path) (Map.keys (manifest ^. #seeds))) $ \other ->+        when (pathsOverlap path other || pathsHaveConflictingSpelling path other) $ Left (SeedPathOverlap path other)+  where+    bad path = Left . InvalidSeedOwners path
src/Seihou/Core/Types.hs view
@@ -15,10 +15,13 @@     lifecycleToText,     SeedOutcome (..),     SeedRecord (..),+    SeedOwnership (..),     PatchOp (..),     isAdditivePatchOp,     Step (..),     Command (..),+    CommandLifecycle (..),+    CheckoutCommand (..),     Dependency (..),     simpleDep,     depModuleNames,@@ -227,11 +230,16 @@   }   deriving stock (Eq, Show, Generic) +-- | Application commands use success receipts; checkout commands repeat on explicit sync.+data CommandLifecycle = ApplicationCommand | CheckoutCommandLifecycle+  deriving stock (Eq, Ord, Show, Generic)+ -- | A shell command to run after file generation. data Command = Command   { run :: !Text,     workDir :: !(Maybe Text),-    condition :: !(Maybe Expr)+    condition :: !(Maybe Expr),+    lifecycle :: !CommandLifecycle   }   deriving stock (Eq, Show, Generic) @@ -442,7 +450,8 @@  -- | Filesystem operations produced by the generation engine. data Operation-  = CheckoutOutputOp+  = SeedFileOp {dest :: !FilePath, content :: !Text, strategy :: !Strategy, moduleName :: !ModuleName}+  | CheckoutOutputOp       { dest :: !FilePath,         spec :: !CheckoutOutputSpec,         moduleName :: !ModuleName@@ -463,7 +472,8 @@       { command :: !Text,         workDir :: !(Maybe FilePath),         moduleName :: !ModuleName,-        occurrence :: !Int+        occurrence :: !Int,+        lifecycle :: !CommandLifecycle       }   | PatchFileOp       { dest :: !FilePath,@@ -486,6 +496,7 @@ isAdditiveOperation :: Operation -> Bool isAdditiveOperation (PatchFileOp _ _ op' _ _) = isAdditivePatchOp op' isAdditiveOperation CheckoutOutputOp {} = False+isAdditiveOperation SeedFileOp {} = False isAdditiveOperation WriteFileOp {} = False isAdditiveOperation CopyFileOp {} = False isAdditiveOperation CreateDirOp {} = False@@ -586,7 +597,9 @@     -- explicit shared-write evidence introduced by schema 7.     TargetedAdditiveSharedPathUpdate   | SeedReceipts+  | SeedOwnerClaims   | CheckoutLocalOutputs+  | CheckoutLocalCommands   deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)  -- | What the manifest knows about how the owners of a path write it.@@ -702,6 +715,16 @@   }   deriving stock (Eq, Show, Generic) +-- | Resolved repeatable setup, owned by a qualified instance in its application.+-- List order is execution order, never completion history.+data CheckoutCommand = CheckoutCommand+  { moduleName :: !ModuleName,+    occurrence :: !Int,+    command :: !Text,+    workDir :: !(Maybe FilePath)+  }+  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@@ -727,6 +750,7 @@     context :: !(Maybe Text),     instances :: ![AppliedInstanceState],     commandReceipts :: !(Map CommandFingerprint CommandReceipt),+    checkoutCommands :: ![CheckoutCommand],     appliedAt :: !UTCTime   }   deriving stock (Eq, Show, Generic)@@ -951,6 +975,7 @@ data CompositionWarning   = FileOverwritten FilePath ModuleName ModuleName   | ContentMerged FilePath ModuleName ModuleName+  | SeedContentIgnored FilePath ModuleName ModuleName   deriving stock (Eq, Show, Generic)  -- | Status of a tracked file relative to its manifest hash.@@ -978,10 +1003,17 @@  data SeedRecord = SeedRecord   { moduleName :: !ModuleName,-    applicationIds :: !(Set ApplicationId),+    ownership :: !SeedOwnership,     outcome :: !SeedOutcome,     settledAt :: !UTCTime   }+  deriving stock (Eq, Show, Generic)++-- | Active claims are separate from immutable first-settlement provenance.+-- Older receipts cannot identify which instances contributed to a path.+data SeedOwnership+  = LegacySeedOwners !(Set ApplicationId)+  | KnownSeedOwners !(Map ApplicationId (Set ModuleName))   deriving stock (Eq, Show, Generic)  -- | A typed, portable specification reconstructed in each working copy.
src/Seihou/Dhall/Eval.hs view
@@ -99,7 +99,7 @@         -- Force lazy decoder thunks that may contain 'error' calls         mapM_ (\v -> evaluate (v ^. #type_)) (m ^. #vars)         mapM_ (\s -> evaluate (s ^. #lifecycle) >> evaluate (s ^. #strategy) >> evaluate (s ^. #condition) >> mapM_ evaluate (s ^. #patch)) (m ^. #steps)-        mapM_ (\c -> mapM_ evaluate (c ^. #condition)) (m ^. #commands)+        mapM_ (\c -> evaluate (c ^. #lifecycle) >> mapM_ evaluate (c ^. #condition)) ((m ^. #commands) <> concatMap (^. #commands) (m ^. #removal))         mapM_ (\p -> evaluate (p ^. #condition)) (m ^. #prompts)         pure m       Failure e -> throwIO e@@ -711,20 +711,19 @@ -- | Decoder for Command from a Dhall record. -- The @when@ field is parsed via 'parseExpr' into an 'Expr' AST. commandDecoder :: Decoder Command-commandDecoder =-  record-    ( mkCommand-        <$> field "run" strictText-        <*> field "workDir" (maybe strictText)-        <*> field "when" (maybe strictText)-    )+commandDecoder = Decoder decode (expected tagged)   where-    mkCommand run workDir whenText =-      Command-        { run = run,-          workDir = workDir,-          condition = parseWhen whenText-        }+    decode expr@(Dhall.RecordLit fields)+      | DhallMap.member "action" fields && DhallMap.member "run" fields =+          error "Command must contain either action or run, not both"+      | DhallMap.member "action" fields = extract tagged expr+      | otherwise = extract legacy expr+    decode expr = extract tagged expr+    tagged = record (mkTagged <$> field "action" actionDecoder <*> field "workDir" (maybe strictText) <*> field "when" (maybe strictText))+    legacy = record (mkLegacy <$> field "run" strictText <*> field "workDir" (maybe strictText) <*> field "when" (maybe strictText))+    actionDecoder = union (((\text -> (ApplicationCommand, text)) <$> constructor "Apply" strictText) <> ((\text -> (CheckoutCommandLifecycle, text)) <$> constructor "Checkout" strictText))+    mkTagged (lifecycle, run) workDir whenText = Command {run, workDir, lifecycle, condition = parseWhen whenText}+    mkLegacy run workDir whenText = Command {run, workDir, lifecycle = ApplicationCommand, condition = parseWhen whenText}  -- | Parse an optional @when@ expression text into an 'Expr'. -- Returns 'Nothing' for 'Nothing' input, 'Just expr' on success, or calls
src/Seihou/Effect/Filesystem.hs view
@@ -1,6 +1,8 @@ module Seihou.Effect.Filesystem   ( Filesystem (..),     PathEntry (..),+    ExclusiveCreateResult (..),+    createFileExclusive,     inspectPath,     createLink,     removeLink,@@ -20,6 +22,7 @@   ) where +import Seihou.Filesystem.Exclusive (ExclusiveCreateResult (..)) import Seihou.Prelude  -- | A filesystem entry, observed without following its final component.@@ -27,6 +30,7 @@   deriving stock (Eq, Show, Generic)  data Filesystem :: Effect where+  CreateFileExclusive :: FilePath -> Text -> Filesystem m ExclusiveCreateResult   InspectPath :: FilePath -> Filesystem m PathEntry   CreateDirectoryLink :: FilePath -> FilePath -> Filesystem m ()   RemoveLink :: FilePath -> Filesystem m ()@@ -98,3 +102,6 @@  normalizePath :: (Filesystem :> es) => FilePath -> Eff es FilePath normalizePath = send . NormalizePath++createFileExclusive :: (Filesystem :> es) => FilePath -> Text -> Eff es ExclusiveCreateResult+createFileExclusive path content = send (CreateFileExclusive path content)
src/Seihou/Effect/FilesystemInterp.hs view
@@ -1,12 +1,16 @@ module Seihou.Effect.FilesystemInterp   ( runFilesystem,+    createFileExclusiveWith,   ) where  import Control.Exception (IOException, catch, throwIO) import Control.Monad (when)+import Data.ByteString qualified as BS+import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as TIO-import Seihou.Effect.Filesystem (Filesystem (..), PathEntry (..))+import Seihou.Effect.Filesystem (ExclusiveCreateResult (..), Filesystem (..), PathEntry (..))+import Seihou.Filesystem.Exclusive (createFileExclusiveWith) import Seihou.Prelude import System.Directory qualified as Dir import System.IO.Error (isDoesNotExistError)@@ -15,6 +19,7 @@ -- Delegates to System.Directory and Data.Text.IO. runFilesystem :: (IOE :> es) => Eff (Filesystem : es) a -> Eff es a runFilesystem = interpret $ \_ -> \case+  CreateFileExclusive path content -> liftIO (createFileExclusiveWith (\handle -> BS.hPut handle (TE.encodeUtf8 content)) path)   InspectPath path -> liftIO $ do     linked <- Dir.pathIsSymbolicLink path `catch` \(err :: IOException) -> if isDoesNotExistError err then pure False else throwIO err     if linked
src/Seihou/Effect/FilesystemPure.hs view
@@ -9,7 +9,7 @@ import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Effectful.State.Static.Local (State, get, modify, put, runState)-import Seihou.Effect.Filesystem (Filesystem (..), PathEntry (..))+import Seihou.Effect.Filesystem (ExclusiveCreateResult (..), Filesystem (..), PathEntry (..)) import Seihou.Prelude  -- | In-memory filesystem state for testing.@@ -31,6 +31,11 @@   where     handler :: (State PureFS :> es') => EffectHandler Filesystem es'     handler _ = \case+      CreateFileExclusive path content -> do+        fs <- get @PureFS+        if Map.member path (fs ^. #files) || Map.member path (fs ^. #links) || Set.member path (fs ^. #dirs)+          then pure ExclusiveOccupied+          else modify @PureFS (#files %~ Map.insert path content) >> pure ExclusiveCreated       InspectPath path -> do         fs <- get @PureFS         pure $ case Map.lookup path (fs ^. #links) of
src/Seihou/Effect/ManifestStore.hs view
@@ -5,7 +5,10 @@   ) where +import Control.Exception (throw)+import Data.Text qualified as T import Seihou.Core.Types (Manifest)+import Seihou.Manifest.Types (manifestEncodingLoss) import Seihou.Prelude  data ManifestStore :: Effect where@@ -18,4 +21,6 @@ readManifest = send ReadManifest  writeManifest :: (ManifestStore :> es) => Manifest -> Eff es ()-writeManifest m = send (WriteManifest m)+writeManifest m = case manifestEncodingLoss m of+  [] -> send (WriteManifest m)+  problems -> throw (userError (T.unpack ("refusing to write invalid manifest: " <> T.intercalate "; " problems)))
src/Seihou/Engine/Execute.hs view
@@ -55,6 +55,7 @@ operationDestination (WriteFileOp dest _ _) = Just dest operationDestination (CopyFileOp _ dest) = Just dest operationDestination (PatchFileOp dest _ _ _ _) = Just dest+operationDestination SeedFileOp {} = Nothing operationDestination CheckoutOutputOp {} = Nothing operationDestination CreateDirOp {} = Nothing operationDestination RunCommandOp {} = Nothing@@ -69,6 +70,7 @@   Operation ->   Eff es (Maybe (FilePath, FileRecord)) executeOp targetDir ownerFor modeFor now op = case op of+  SeedFileOp {} -> error "seed operation passed to managed execution"   CheckoutOutputOp {} -> pure Nothing   WriteFileOp dest content strat -> do     let fullPath = targetDir </> dest@@ -139,6 +141,7 @@     else T.unlines (map formatOp ops)   where     formatOp :: Operation -> Text+    formatOp SeedFileOp {dest} = "Seed " <> T.pack dest <> " (create once)"     formatOp (WriteFileOp dest _ _) = "  write " <> T.pack dest     formatOp (CheckoutOutputOp dest (DirectorySymlink source) _) = "  link  " <> T.pack dest <> " -> " <> T.pack source     formatOp (CreateDirOp path) = "  mkdir " <> T.pack path
src/Seihou/Engine/Migrate.hs view
@@ -8,6 +8,7 @@     -- * Engine entry points     classifyMigration,     executeMigration,+    validateSeedMigration,   ) where @@ -15,14 +16,16 @@ import Data.List (nub, sortBy) import Data.Map.Strict qualified as Map import Data.Ord (Down (..))+import Data.Set qualified as Set import Data.Text qualified as T import Data.Time (UTCTime)+import Seihou.Core.Checkout (pathsOverlap) import Seihou.Core.Migration   ( Migration (..),     MigrationOp (..),     MigrationPlan (..),   )-import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath) import Seihou.Core.Types   ( AppliedModule (..),     FileRecord (..),@@ -45,6 +48,7 @@ import Seihou.Prelude import System.Exit (ExitCode (..)) import System.FilePath (takeDirectory)+import System.FilePath.Posix qualified as P  -- ---------------------------------------------------------------------------- -- Types@@ -106,7 +110,12 @@   Manifest ->   MigrationPlan ->   Eff es (Either MigrationExecError ExecutedMigrationPlan)-classifyMigration manifest plan = do+classifyMigration manifest plan = case validateSeedMigration (Map.keysSet (manifest ^. #seeds)) (concatMap (^. #ops) (plan ^. #steps)) of+  Left err -> pure (Left err)+  Right () -> classifyUnprotectedMigration manifest plan++classifyUnprotectedMigration :: (Filesystem :> es) => Manifest -> MigrationPlan -> Eff es (Either MigrationExecError ExecutedMigrationPlan)+classifyUnprotectedMigration manifest plan = do   opsResult <- traverse (classifyOp manifest) (concatMap (^. #ops) (plan ^. #steps))   pure $ do     ops <- sequence opsResult@@ -204,7 +213,18 @@   -- | New @genAt@ timestamp to stamp on the rewritten manifest.   UTCTime ->   Eff es (Either MigrationExecError Manifest)-executeMigration force plan manifest now = do+executeMigration force plan manifest now = case validateProtectedMutationPaths (Map.keysSet (manifest ^. #seeds)) (concatMap instancePaths (plan ^. #ops)) of+  Left err -> pure (Left err)+  Right () -> executeUnprotectedMigration force plan manifest now+  where+    instancePaths (MoveFileInst source dest _) = [source, dest]+    instancePaths (MoveDirInst source dest) = [source, dest]+    instancePaths (DeleteFileInst path _) = [path]+    instancePaths (DeleteDirInst path) = [path]+    instancePaths RunCommandInst {} = []++executeUnprotectedMigration :: (Filesystem :> es, Process :> es) => Bool -> ExecutedMigrationPlan -> Manifest -> UTCTime -> Eff es (Either MigrationExecError Manifest)+executeUnprotectedMigration force plan manifest now = do   let conflicts =         [ p         | inst <- plan ^. #ops,@@ -367,3 +387,17 @@ isPrefixOfPath [] _ = True isPrefixOfPath _ [] = False isPrefixOfPath (x : xs) (y : ys) = x == y && isPrefixOfPath xs ys++validateSeedMigration :: Set FilePath -> [MigrationOp] -> Either MigrationExecError ()+validateSeedMigration seeds = validateProtectedMutationPaths seeds . concatMap paths+  where+    paths MoveFile {src, dest} = [src, dest]+    paths MoveDir {src, dest} = [src, dest]+    paths DeleteFile {path} = [path]+    paths DeleteDir {path} = [path]+    paths RunCommand {} = []++validateProtectedMutationPaths :: Set FilePath -> [FilePath] -> Either MigrationExecError ()+validateProtectedMutationPaths seeds paths = case [path | path <- paths, seed <- Set.toAscList seeds, pathsOverlap (P.normalise path) (P.normalise seed) || pathsHaveConflictingSpelling (P.normalise path) (P.normalise seed)] of+  path : _ -> Left (MigrationUnsafePath "seed ownership" path "declarative migrations must not mutate a project-owned seed or a path being released to the project; publish a compatible producer migration")+  [] -> Right ()
src/Seihou/Engine/Plan.hs view
@@ -20,10 +20,10 @@ import Dhall qualified import Dhall.Core qualified as DhallCore import Dhall.Src (Src)-import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutPaths)+import Seihou.Core.Checkout (CheckoutError (..), pathsOverlap, validateCheckoutPaths) import Seihou.Core.Expr (evalExpr) import Seihou.Core.Module (checkCheckoutSteps, checkSeedSteps)-import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Path (validateCanonicalProjectPath, validateProjectRelativePath) import Seihou.Core.Types import Seihou.Engine.DhallJSON (dhallExprToJSON) import Seihou.Engine.Template (renderCommand, renderDestPath, renderTemplate, renderTemplateText)@@ -48,7 +48,13 @@   if null allErrors     then case compileCommands modName vars (modul ^. #commands) of       Left cmdErrs -> pure (Left cmdErrs)-      Right cmdOps -> pure (Right (deduplicateDirs (concat allOps) ++ cmdOps))+      Right cmdOps -> do+        let operations = deduplicateDirs (concat allOps) ++ cmdOps+            seedPaths = [dest | SeedFileOp {dest} <- operations]+            bytePaths = [dest | op <- operations, dest <- case op of WriteFileOp {dest} -> [dest]; CopyFileOp {dest} -> [dest]; PatchFileOp {dest} -> [dest]; CheckoutOutputOp {dest} -> [dest]; _ -> []]+            mixed = ["seed '" <> T.pack seed <> "' overlaps another output '" <> T.pack other <> "'" | seed <- seedPaths, other <- bytePaths, pathsOverlap seed other]+            removals = ["removal action targets seed '" <> T.pack seed <> "'" | removal <- maybe [] pure (modul ^. #removal), action <- removal ^. #steps, Right dest <- [renderCommand (action ^. #dest) vars], seed <- seedPaths, pathsOverlap seed (T.unpack dest)]+        pure (if null (mixed <> removals) then Right operations else Left (mixed <> removals))     else pure (Left (concat allErrors))  -- | Compile commands into 'RunCommandOp' operations, interpolating@@ -66,14 +72,15 @@           Left cmdErrs -> let (counts, ops, _) = state in (counts, ops, errs ++ cmdErrs)           Right (runText, commandWorkDir) ->             let (counts, ops, _) = state-                key = (runText, commandWorkDir)+                key = (cmd ^. #lifecycle, runText, if cmd ^. #lifecycle == CheckoutCommandLifecycle then Just (maybe "." id commandWorkDir) else commandWorkDir)                 occurrence = Map.findWithDefault 0 key counts                 operation =                   RunCommandOp                     { command = runText,                       workDir = commandWorkDir,                       moduleName = modName,-                      occurrence = occurrence+                      occurrence = occurrence,+                      lifecycle = cmd ^. #lifecycle                     }              in (Map.insert key (occurrence + 1) counts, ops ++ [operation], errs) @@ -95,7 +102,9 @@             Right renderedWd ->               case validateRenderedCommandWorkDir renderedWd of                 Left err -> Left [err]-                Right safeWd -> Right (runText, Just (T.unpack safeWd))+                Right safeWd -> do+                  if cmd ^. #lifecycle == CheckoutCommandLifecycle && safeWd /= "." then first pure (validateCanonicalProjectPath safeWd) else Right ()+                  Right (runText, Just (T.unpack safeWd))  -- | Compile a single step into operations (or skip it). -- If the step has a patch operation, it produces a 'PatchFileOp'; otherwise@@ -113,7 +122,16 @@         Just expr -> evalExpr vars expr   if not shouldRun     then pure (Right [])-    else case step ^. #patch of+    else do+      compiled <- compileActiveStep+      pure $ compiled >>= if step ^. #lifecycle == Seed then traverseSeed else Right+  where+    traverseSeed ops = traverse convert [op | op <- ops, case op of CreateDirOp {} -> False; _ -> True]+    convert (WriteFileOp dest content strategy) = do+      first pure (validateCanonicalProjectPath (T.pack dest))+      Right (SeedFileOp dest content strategy modName)+    convert _ = Left ["seed step must compile to a supported whole-file write"]+    compileActiveStep = case step ^. #patch of       Just _ -> compilePatchStep baseDir vars modName step       Nothing -> case step ^. #strategy of         Copy -> compileCopyStep baseDir vars step
src/Seihou/Engine/Preview.hs view
@@ -73,6 +73,7 @@       annotation = strategyName strat,       module_ = Map.lookup dest ownerMap     }+opToPreview _ _ _ SeedFileOp {dest, moduleName} = FilePreview {status = FsNew, path = dest, annotation = "seed (create once)", module_ = Just moduleName} opToPreview _ _ _ (CheckoutOutputOp dest (DirectorySymlink source) _) = LinkPreview dest source opToPreview _ _ _ (CreateDirOp path) = DirPreview path opToPreview mDiff ownerMap _ (CopyFileOp _ dest) =
src/Seihou/Engine/Reconcile.hs view
@@ -1,6 +1,9 @@ module Seihou.Engine.Reconcile   ( DesiredFileOwner (..),     DesiredFile (..),+    SeedAction (..),+    planSeedReconciliation,+    seedCandidateReceipts,     ReconciliationReason (..),     ObservedFile (..),     PlannedFileState (..),@@ -22,18 +25,22 @@   ) where -import Control.Monad (foldM)+import Control.Monad (foldM, forM, forM_, unless, when) 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 import Data.Text qualified as T-import Seihou.Core.Path (validateProjectRelativePath)+import Data.Time (UTCTime)+import Seihou.Core.Checkout (pathsOverlap)+import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath)+import Seihou.Core.Seed (seedApplicationIds) import Seihou.Core.Types hiding (KeepCurrent) import Seihou.Effect.BaselineStore (BaselineError, BaselineStore, readBaseline)-import Seihou.Effect.Filesystem (Filesystem, doesFileExist, readFileText)+import Seihou.Effect.Filesystem (Filesystem, PathEntry (..), doesFileExist, inspectPath, readFileText) import Seihou.Engine.Section (applyTextPatch)+import Seihou.Engine.Seed import Seihou.Engine.ThreeWayMerge (MergeOutcome (..), threeWayMerge) import Seihou.Manifest.Hash (hashContent) import Seihou.Prelude@@ -64,7 +71,9 @@   deriving stock (Eq, Generic, Show)  data ReconciliationReason-  = MissingTrustedBaseline+  = AdoptingSeedFile+  | AdoptingMissingSeedFile+  | MissingTrustedBaseline   | CurrentFileMissing   | MergeDriverUnavailable Text   | OverlappingEdits@@ -96,8 +105,24 @@   }   deriving stock (Eq, Generic, Show) +-- | Metadata-only seed actions carry neither bytes nor content snapshots.+-- Only creation carries a candidate payload. Supplier is explicit; claims may+-- include multiple qualified instances per application.+data SeedAction = SeedAction+  { path :: !FilePath,+    supplier :: !ModuleName,+    claims :: !(Map ApplicationId (Set ModuleName)),+    decision :: !SeedDecision,+    newContent :: !(Maybe Text)+  }+  deriving stock (Eq, Show, Generic)+ data FileReconciliation-  = FileCreate DesiredFile PlannedFileState ObservedFile+  = FileSeedCreate SeedAction PathEntry+  | FileSeedSettle SeedAction+  | FileSeedRelease SeedAction FileRecord+  | FileSeedAbandon FilePath SeedRecord+  | FileCreate DesiredFile PlannedFileState ObservedFile   | FileUpdate DesiredFile PlannedFileState ObservedFile (Maybe FileRecord)   | FileAutoMerge DesiredFile PlannedFileState ObservedFile (Maybe FileRecord)   | FileUnchanged DesiredFile PlannedFileState ObservedFile (Maybe FileRecord)@@ -142,7 +167,11 @@     partial = not (Set.null retainedOwners)  data ReconciliationError-  = InvalidReconciliationPath FilePath Text+  = SeedReconciliationFailed Text+  | SeedReleaseBlockedByOwners FilePath (Set ApplicationId)+  | SeedPathAlsoManaged FilePath+  | InvalidSeedAdoptionChoice FilePath+  | InvalidReconciliationPath FilePath Text   | MissingDesiredOwner FilePath   | DesiredOwnerOutsideSelection FilePath (Set ApplicationId)   | SharedPathRequiresApplications FilePath (Set ApplicationId)@@ -211,6 +240,91 @@         then Right <$> readFileText sourcePath         else pure (Left (CopySourceUnavailable sourcePath)) +-- | Partition lifecycle operations before managed materialization or orphan+-- classification. Seed observation never reads project contents. Across selected+-- applications, operations arrive in recorded application order.+planSeedReconciliation ::+  (Filesystem :> es, BaselineStore :> es, IOE :> es) =>+  FilePath ->+  FilePath ->+  Manifest ->+  Set ApplicationId ->+  [Operation] ->+  Map FilePath DesiredFileOwner ->+  Map FilePath (Map ApplicationId (Set ModuleName)) ->+  Eff es (Either ReconciliationError ReconciliationPlan)+planSeedReconciliation root managedRoot manifest selected operations ownerMap claims = do+  let seedOps = firstSeeds Set.empty operations+      seedPaths = Map.keysSet claims+      managedOps = filter (\case SeedFileOp {} -> False; _ -> True) operations+      checkoutPaths = Set.fromList [dest | CheckoutOutputOp {dest} <- operations]+      managedPaths = Map.keysSet (groupFileOperations managedOps) `Set.union` checkoutPaths+      overlaps a b = pathsOverlap a b || pathsHaveConflictingSpelling a b+      retainedOwners receipt = seedApplicationIds receipt Set.\\ selected+      gate = do+        forM_ (Set.toAscList seedPaths) $ \path -> do+          when (any (overlaps path) (Set.toAscList managedPaths)) (Left (SeedPathAlsoManaged path))+          case Map.lookup path (manifest ^. #files) of+            Just record | not (Set.null (Set.difference (record ^. #applicationIds) selected)) -> Left (SeedReleaseBlockedByOwners path (Set.difference (record ^. #applicationIds) selected))+            _ -> Right ()+        forM_ (Map.toAscList (manifest ^. #seeds)) $ \(path, receipt) ->+          forM_ (Set.toAscList managedPaths) $ \managed ->+            when (overlaps path managed && (Set.member managed checkoutPaths || path /= managed || not (Set.null (retainedOwners receipt)))) (Left (SeedPathAlsoManaged path))+  case gate of+    Left err -> pure (Left err)+    Right () -> do+      seeded <- planSeeds root manifest (Map.map (Set.unions . Map.elems) claims) seedOps+      adoptionEntries <- forM (Set.toAscList (managedPaths `Set.intersection` Map.keysSet (manifest ^. #seeds))) $ \path -> do+        entry <- inspectPath (root </> path)+        pure (path, entry)+      case [path | (path, entry) <- adoptionEntries, entry `notElem` [EntryFile, EntryMissing]] of+        path : _ -> pure (Left (SeedReconciliationFailed ("seed adoption '" <> T.pack path <> "' requires a regular file or absence; move the directory or symlink aside")))+        [] -> case seeded of+          Left errors -> pure (Left (SeedReconciliationFailed (T.intercalate "; " (map (^. #message) errors))))+          Right seedPlans -> do+            managed <- planReconciliation managedRoot (manifest & #files %~ (`Map.withoutKeys` seedPaths)) selected managedOps ownerMap+            pure $ do+              plan <- managed+              let actions = Map.fromList [(planned ^. #dest, action planned) | planned <- seedPlans]+                  abandoned = Map.mapWithKey FileSeedAbandon (Map.filterWithKey (\path receipt -> Set.notMember path seedPaths && Set.notMember path managedPaths && not (Set.null (seedApplicationIds receipt `Set.intersection` selected))) (manifest ^. #seeds))+                  adoptions = foldl' makeAdoption (plan ^. #files) adoptionEntries+              Right (plan & #files .~ Map.unions [actions, adoptions, abandoned] & #requiredDirectories %~ Set.union (Set.fromList [takeDirectory (planned ^. #dest) | planned <- seedPlans, planned ^. #decision == SeedWrite, takeDirectory (planned ^. #dest) /= "."]))+  where+    firstSeeds _ [] = []+    firstSeeds seen (op@SeedFileOp {dest} : rest)+      | Set.member dest seen = firstSeeds seen rest+      | otherwise = op : firstSeeds (Set.insert dest seen) rest+    firstSeeds seen (_ : rest) = firstSeeds seen rest+    action planned =+      let path = planned ^. #dest+          seed = SeedAction path (planned ^. #supplier) (Map.findWithDefault Map.empty path claims) (planned ^. #decision) (if planned ^. #decision == SeedWrite then Just (planned ^. #content) else Nothing)+       in case planned ^. #decision of+            SeedWrite -> FileSeedCreate seed EntryMissing+            SeedReleaseManaged -> case Map.lookup path (manifest ^. #files) of Just record -> FileSeedRelease seed record; Nothing -> FileSeedSettle seed+            _ -> FileSeedSettle seed+    makeAdoption files (path, entry) = Map.adjust (adopt (entry == EntryMissing)) path files+    adopt missing reconciliation = case desiredFor reconciliation of+      Just (desired, current, observed) -> FileConflict desired current "" (if missing then AdoptingMissingSeedFile else AdoptingSeedFile) observed Nothing Nothing+      Nothing -> reconciliation+    desiredFor reconciliation = case reconciliation of+      FileCreate desired _ observed -> Just (desired, "", observed)+      FileUpdate desired state observed _ -> Just (desired, state ^. #appliedContent, observed)+      FileAutoMerge desired state observed _ -> Just (desired, state ^. #appliedContent, observed)+      FileUnchanged desired state observed _ -> Just (desired, state ^. #appliedContent, observed)+      FileConflict desired current _ _ observed _ _ -> Just (desired, current, observed)+      _ -> Nothing++-- | Complete candidate claims, preserving prior settlement on every surviving+-- path. Used once by update's manifest fold, never as a successful preview receipt.+seedCandidateReceipts :: UTCTime -> Manifest -> ReconciliationPlan -> Map FilePath SeedRecord+seedCandidateReceipts now manifest plan = Map.fromList [receipt action | reconciliation <- Map.elems (plan ^. #files), action <- case reconciliation of FileSeedCreate a _ -> [a]; FileSeedSettle a -> [a]; FileSeedRelease a _ -> [a]; _ -> []]+  where+    receipt action =+      let path = action ^. #path+          prior = Map.lookup path (manifest ^. #seeds)+          outcome = case action ^. #decision of SeedWrite -> SeedCreated; SeedReleaseManaged -> SeedReleasedFromManaged; _ -> SeedFoundExisting+       in (path, SeedRecord (maybe (action ^. #supplier) (^. #moduleName) prior) (KnownSeedOwners (action ^. #claims)) (maybe outcome (^. #outcome) prior) (maybe now (^. #settledAt) prior))+ -- | Backend-parametric planner. Tests use maps for disk, copy-source, and -- baseline reads while production supplies effects. The function is read-only. planReconciliationWith ::@@ -262,7 +376,8 @@     baseline :: !(Maybe Text),     priorRecord :: !(Maybe FileRecord),     observed :: !ObservedFile,-    missingTrustedBaseline :: !Bool+    missingTrustedBaseline :: !Bool,+    lineSetPatch :: !Bool   }   deriving stock (Generic) @@ -403,7 +518,8 @@           baseline = trusted,           priorRecord = prior,           observed = observe current,-          missingTrustedBaseline = missing+          missingTrustedBaseline = missing,+          lineSetPatch = all (\case PatchFileOp {op = AppendLineIfAbsent} -> True; _ -> False) pathOperations         }   where     operationPath (operation : _) = case operationDestination operation of@@ -490,6 +606,14 @@            in pure (Right (path, FileUnchanged desired state observed prior))       | current == generated =           pure (Right (path, FileUnchanged desired (unchangedState generated current) observed prior))+      -- Line-set additions commute with project edits, including an append at+      -- the same EOF. Apply only newly declared lines, preserving intentional+      -- removal of older lines. Other patches retain ordinary three-way merge.+      | context ^. #lineSetPatch =+          let additions = filter (\line -> T.stripEnd line `notElem` map T.stripEnd (T.lines baseline)) (T.lines generated)+           in pure $ case applyTextPatch AppendLineIfAbsent (desired ^. #moduleName) "#" current (T.unlines additions) of+                Right merged -> Right (path, fromMerge current (MergeClean merged))+                Left reason -> Left (PatchMaterializationFailed path AppendLineIfAbsent (desired ^. #moduleName) reason)       | otherwise = do           outcome <- mergeContents baseline current generated           pure $ Right (path, fromMerge current outcome)@@ -551,6 +675,7 @@ resolveFileConflict path choice plan = case Map.lookup path (plan ^. #files) of   Nothing -> Left (ReconciliationPathNotFound path)   Just (FileConflict _ _ _ _ _ _ _) | choice == AbortUpdate -> Left (UpdateAborted path)+  Just (FileConflict _ _ _ reason _ _ _) | (reason == AdoptingMissingSeedFile && choice /= AcceptGenerated) || (reason == AdoptingSeedFile && choice == WriteConflictMarkers) -> Left (InvalidSeedAdoptionChoice path)   Just (FileConflict desired current markers reason observed prior _) ->     let applied = case choice of           AcceptGenerated -> (desired ^. #generatedContent)@@ -592,6 +717,10 @@   where     emptySummary = ReconciliationSummary 0 0 0 0 0 0 0 0     count summary reconciliation = case reconciliation of+      FileSeedCreate _ _ -> addCreate summary+      FileSeedRelease _ _ -> addSharedOwnership summary+      FileSeedAbandon _ _ -> addSharedOwnership summary+      FileSeedSettle _ -> addUnchanged summary       FileCreate _ _ _ -> addCreate summary       FileUpdate _ _ _ _ -> addUpdate summary       FileAutoMerge _ _ _ _ -> addMerge summary@@ -625,7 +754,7 @@     }  reconciliationMutationPaths :: ReconciliationPlan -> Set FilePath-reconciliationMutationPaths = Map.keysSet . (^. #files)+reconciliationMutationPaths = Map.keysSet . Map.filter (\case FileSeedSettle {} -> False; FileSeedRelease {} -> False; FileSeedAbandon {} -> False; _ -> True) . (^. #files)  unresolvedPaths :: ReconciliationPlan -> Set FilePath unresolvedPaths plan = Map.keysSet (Map.filter unresolved (plan ^. #files))
src/Seihou/Engine/Remove.hs view
@@ -7,6 +7,7 @@     ExecutedRemovalPlan (..),     CheckoutRemovalSelection (..),     selectCheckoutRemoval,+    selectRecordedRemoval,     computeRemovalPlan,     executeRemoval,     buildRemovalOps,@@ -26,13 +27,15 @@ import Data.Time (UTCTime) import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName) import Seihou.Core.Checkout-import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath)+import Seihou.Core.Seed import Seihou.Core.Types import Seihou.Effect.Filesystem (Filesystem, doesFileExist, readFileText, removeDirectoryIfEmpty, removeFile, writeFileText) import Seihou.Engine.Section (removeSection) import Seihou.Manifest.Hash (hashContent) import Seihou.Prelude import System.FilePath (takeDirectory)+import System.FilePath.Posix qualified as P  -- ============================================================ -- Legacy types (used by current CLI handler, Milestone 4 replaces)@@ -61,7 +64,8 @@  -- | A concrete removal operation ready to execute. data RemovalOp-  = -- | Delete a file, with its current status.+  = KeepSeedOp FilePath+  | -- | Delete a file, with its current status.     DeleteFileOp FilePath RemovalFileStatus   | -- | Strip this module's section markers from a file.     StripSectionOp FilePath@@ -134,7 +138,7 @@   Eff es Manifest executeRemoval manifest plan keepSet now = do   let toDelete = filesToDelete plan keepSet-  mapM_ removeFile toDelete+  mapM_ removeFile (filter (not . protectedSeedPath manifest) toDelete)   cleanupEmptyDirs toDelete   pure (removeFromManifest manifest (plan ^. #targetModule) now) @@ -172,7 +176,13 @@   ModuleName ->   RemovalStep ->   Eff es (Either RemovalError RemovalOp)-buildStepOp manifest _modName step = case step ^. #action of+buildStepOp manifest modName step = case validateRemovalPath (case step ^. #action of RemoveFileAction -> "remove-file destination"; RemoveSectionAction -> "remove-section destination"; RewriteFileAction -> "rewrite-file destination") (step ^. #dest) of+  Left err -> pure (Left err)+  Right path | protectedSeedPath manifest path -> pure (Right (KeepSeedOp path))+  _ -> buildUnprotectedStepOp manifest modName step++buildUnprotectedStepOp :: (Filesystem :> es) => Manifest -> ModuleName -> RemovalStep -> Eff es (Either RemovalError RemovalOp)+buildUnprotectedStepOp manifest _modName step = case step ^. #action of   RemoveFileAction ->     case validateRemovalPath "remove-file destination" (step ^. #dest) of       Left err -> pure (Left err)@@ -193,10 +203,11 @@       Right (RewriteOp dest src)  buildCommandOp :: Command -> Either RemovalError RemovalOp-buildCommandOp command =-  case traverse (validateRemovalPath "remove-command workDir") (command ^. #workDir) of-    Left err -> Left err-    Right safeWorkDir -> Right (RemovalCommandOp (command ^. #run) (fmap T.pack safeWorkDir))+buildCommandOp command+  | command ^. #lifecycle == CheckoutCommandLifecycle = Left (RemovalUnsafePath "remove-command" "" "checkout setup is not a removal command")+  | otherwise = case traverse (validateRemovalPath "remove-command workDir") (command ^. #workDir) of+      Left err -> Left err+      Right safeWorkDir -> Right (RemovalCommandOp (command ^. #run) (fmap T.pack safeWorkDir))  validateRemovalPath :: Text -> Text -> Either RemovalError FilePath validateRemovalPath label path =@@ -233,7 +244,10 @@   Eff es Manifest executeRemovalOps manifest plan keepSet now = do   let modName = (plan ^. #targetModule)-  deletedPaths <- foldM (execOp modName keepSet) [] (plan ^. #ops)+  let protected op = case removalOpDestination op of+        Just path | protectedSeedPath manifest path -> KeepSeedOp path+        _ -> op+  deletedPaths <- foldM (execOp modName keepSet) [] (map protected (plan ^. #ops))   cleanupEmptyDirs deletedPaths   pure (removeFromManifest manifest modName now) @@ -245,6 +259,7 @@   [FilePath] ->   RemovalOp ->   Eff es [FilePath]+execOp _ _ acc KeepSeedOp {} = pure acc execOp _ keepSet acc (DeleteFileOp path status) =   if Set.member path keepSet     then pure acc@@ -274,6 +289,16 @@   -- Commands are executed by the CLI handler, not the engine   pure acc +protectedSeedPath :: Manifest -> FilePath -> Bool+protectedSeedPath manifest path = any (\seed -> pathsOverlap (P.normalise path) (P.normalise seed) || pathsHaveConflictingSpelling (P.normalise path) (P.normalise seed)) (Map.keys (manifest ^. #seeds))++removalOpDestination :: RemovalOp -> Maybe FilePath+removalOpDestination (KeepSeedOp path) = Just path+removalOpDestination (DeleteFileOp path _) = Just path+removalOpDestination (StripSectionOp path) = Just path+removalOpDestination (RewriteOp path _) = Just path+removalOpDestination RemovalCommandOp {} = Nothing+ -- | Guess the comment prefix for a file based on its extension. guessCommentPrefix :: FilePath -> Text guessCommentPrefix path@@ -379,9 +404,13 @@ -- | Resolve a name against recorded qualified instances, never installed modules. -- Ambiguous bare names and application choices require explicit selection. selectCheckoutRemoval :: Manifest -> ModuleName -> Maybe ApplicationId -> Either CheckoutError (Maybe CheckoutRemovalSelection)-selectCheckoutRemoval manifest requested selectedApplication = case candidates of+selectCheckoutRemoval = selectRecordedRemoval++-- | Shared selection for seed, checkout-output and checkout-command ownership.+selectRecordedRemoval :: Manifest -> ModuleName -> Maybe ApplicationId -> Either CheckoutError (Maybe CheckoutRemovalSelection)+selectRecordedRemoval manifest requested selectedApplication = case candidates of   []-    | selectedApplication /= Nothing -> Left (checkoutError "checkout_remove_selection" "no matching checkout-owning instance in the selected application")+    | selectedApplication /= Nothing -> Left (checkoutError "checkout_remove_selection" "no matching recorded owning instance in the selected application")     | otherwise -> Right Nothing   [(app, state, qualified)] -> do     let appId = app ^. #applicationId@@ -396,7 +425,7 @@           | other ^. #applicationId /= appId = Just other           | otherwise =               let retained = filter (\instanceState -> qualifiedName (ModuleInstance (instanceState ^. #name) (instanceState ^. #parentVars)) /= qualified) (other ^. #instances)-               in if null retained then Nothing else Just (other & #instances .~ retained & #commandReceipts %~ Map.filter ((/= qualified) . (^. #moduleName)))+               in if null retained then Nothing else Just (other & #instances .~ retained & #commandReceipts %~ Map.filter ((/= qualified) . (^. #moduleName)) & #checkoutCommands %~ filter ((/= qualified) . (^. #moduleName)))         applications = mapMaybe keepApplication (manifest ^. #applications)         files = Map.mapMaybe keepFile (manifest ^. #files)         stillNeeded applied =@@ -405,9 +434,19 @@         removeSelected applied = applied ^. #name /= state ^. #name || applied ^. #parentVars /= state ^. #parentVars || stillNeeded applied         candidate = manifest & #checkoutOutputs .~ outputs & #applications .~ applications & #files .~ files & #modules %~ filter removeSelected         custom = listToMaybe [spec | applied <- manifest ^. #modules, applied ^. #name == state ^. #name, applied ^. #parentVars == state ^. #parentVars, spec <- maybeToList (applied ^. #removal)]-    validateCheckoutRecords candidate []-    pure (Just (CheckoutRemovalSelection appId qualified (state ^. #name) custom candidate))-  matches -> Left (checkoutError "checkout_remove_ambiguous" ("checkout removal is ambiguous; select one recorded instance/application: " <> T.intercalate "; " ["seihou remove " <> qualified ^. #unModuleName <> " --application " <> app ^. #applicationId . #unApplicationId | (app, _, qualified) <- matches]))+    -- Retiring seed-only metadata does not authorize forgetting managed files.+    case custom of+      Nothing+        | any (\record -> record ^. #moduleName == qualified && Set.member appId (record ^. #applicationIds)) (Map.elems (manifest ^. #files)) ->+            Left (checkoutError "remove_managed_policy_required" "this instance also owns managed files; add a removal specification for those files before removing it")+      _ -> Right ()+    withSeeds <-+      first (checkoutError "seed_owner_evidence_required" . renderSeedOwnershipError) $+        foldM (\current path -> dropSeedOwners selected path current) candidate (Map.keys (manifest ^. #seeds))+    first (checkoutError "seed_declaration_invalid" . renderManifestInvariantError) (validateManifestInvariants withSeeds)+    validateCheckoutRecords withSeeds []+    pure (Just (CheckoutRemovalSelection appId qualified (state ^. #name) custom withSeeds))+  matches -> Left (checkoutError "checkout_remove_ambiguous" ("recorded removal is ambiguous; select one recorded instance/application: " <> T.intercalate "; " ["seihou remove " <> qualified ^. #unModuleName <> " --application " <> app ^. #applicationId . #unApplicationId | (app, _, qualified) <- matches]))   where     candidates =       [ (app, state, qualified)@@ -416,5 +455,5 @@         state <- app ^. #instances,         let qualified = qualifiedName (ModuleInstance (state ^. #name) (state ^. #parentVars)),         requested == qualified || requested == state ^. #name,-        any (\record -> Set.member qualified (Map.findWithDefault Set.empty (app ^. #applicationId) (record ^. #owners))) (Map.elems (manifest ^. #checkoutOutputs))+        any (\record -> case record ^. #ownership of KnownSeedOwners owners -> Set.member qualified (Map.findWithDefault Set.empty (app ^. #applicationId) owners); LegacySeedOwners apps -> Set.member (app ^. #applicationId) apps) (Map.elems (manifest ^. #seeds)) || any ((== qualified) . (^. #moduleName)) (app ^. #checkoutCommands) || any (\record -> Set.member qualified (Map.findWithDefault Set.empty (app ^. #applicationId) (record ^. #owners))) (Map.elems (manifest ^. #checkoutOutputs))       ]
+ src/Seihou/Engine/Seed.hs view
@@ -0,0 +1,173 @@+module Seihou.Engine.Seed+  ( SeedError (..),+    SeedDecision (..),+    PlannedSeed (..),+    classifySeed,+    seedOperationClaims,+    planSeeds,+    validateSeedRun,+    executeSeeds,+    renderSeedPlan,+  )+where++import Control.Monad (foldM, forM)+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as T+import Data.Time (UTCTime)+import Seihou.Core.Checkout (pathsOverlap)+import Seihou.Core.Path (pathsHaveConflictingSpelling, validateCanonicalProjectPath)+import Seihou.Core.Seed+import Seihou.Core.Types+import Seihou.Effect.Filesystem+import Seihou.Prelude+import System.FilePath.Posix qualified as P++data SeedError = SeedError {code :: !Text, message :: !Text}+  deriving stock (Eq, Show, Generic)++data SeedDecision = SeedWrite | SeedKeepExisting | SeedRespectDeletion | SeedAlreadySettled | SeedReleaseManaged+  deriving stock (Eq, Show, Generic)++data PlannedSeed = PlannedSeed+  { dest :: !FilePath,+    content :: !Text,+    strategy :: !Strategy,+    supplier :: !ModuleName,+    owners :: !(Set ModuleName),+    observed :: !PathEntry,+    decision :: !SeedDecision+  }+  deriving stock (Eq, Show, Generic)++classifySeed :: Maybe SeedRecord -> Maybe FileRecord -> PathEntry -> Either SeedError SeedDecision+classifySeed _ (Just _) _ = Right SeedReleaseManaged+classifySeed (Just _) Nothing EntryMissing = Right SeedRespectDeletion+classifySeed (Just _) Nothing _ = Right SeedAlreadySettled+classifySeed Nothing Nothing EntryMissing = Right SeedWrite+classifySeed Nothing Nothing EntryFile = Right SeedKeepExisting+classifySeed Nothing Nothing _ = Left (SeedError "seed_destination_occupied" "new seed destination is a symlink or directory; move it aside and retry")++seedOperationClaims :: [Operation] -> Map FilePath (Set ModuleName)+seedOperationClaims operations = Map.fromListWith Set.union [(dest, Set.singleton moduleName) | SeedFileOp {dest, moduleName} <- operations]++-- | Lifecycle transitions belong to update; force is never consent. This pure+-- preflight runs before migrations, writes, or publishing new applied metadata.+validateSeedRun :: Manifest -> [Operation] -> Either [SeedError] ()+validateSeedRun manifest operations = case errors of [] -> Right (); _ -> Left errors+  where+    errors =+      [transition path | SeedFileOp {dest = path} <- operations, Map.member path (manifest ^. #files)]+        <> [transition path | op <- operations, Just path <- [managedDestination op], seed <- Map.keys (manifest ^. #seeds), pathsOverlap path seed]+    transition path = SeedError "seed_lifecycle_transition" ("output '" <> T.pack path <> "' changes lifecycle; run seihou update for its recorded application to review the transition")++managedDestination :: Operation -> Maybe FilePath+managedDestination WriteFileOp {dest} = Just dest+managedDestination CopyFileOp {dest} = Just dest+managedDestination PatchFileOp {dest} = Just dest+managedDestination CheckoutOutputOp {dest} = Just dest+managedDestination _ = Nothing++planSeeds :: (Filesystem :> es) => FilePath -> Manifest -> Map FilePath (Set ModuleName) -> [Operation] -> Eff es (Either [SeedError] [PlannedSeed])+planSeeds root manifest claims operations = do+  results <- forM [op | op@SeedFileOp {} <- operations] $ \case+    SeedFileOp {dest, content, strategy, moduleName} -> do+      let overlaps =+            [other | other <- Map.keys (manifest ^. #files), other /= dest, (pathsOverlap dest other || pathsHaveConflictingSpelling dest other)]+              <> [other | other <- Map.keys (manifest ^. #checkoutOutputs), (pathsOverlap dest other || pathsHaveConflictingSpelling dest other)]+              <> [other | other <- Map.keys (manifest ^. #seeds), other /= dest, (pathsOverlap dest other || pathsHaveConflictingSpelling dest other)]+      case first (SeedError "seed_path_invalid") (validateCanonicalProjectPath (T.pack dest)) of+        Left err -> pure (Left err)+        Right () | not (null overlaps) -> pure (Left (SeedError "seed_path_overlap" ("seed '" <> T.pack dest <> "' overlaps retained output '" <> T.pack (head overlaps) <> "'")))+        Right () -> do+          parents <- checkParents root dest False+          case parents of+            Left err -> pure (Left err)+            Right () -> do+              observed <- inspectPath (root </> dest)+              alias <- checkSpelling root dest observed+              pure $ do+                alias+                decision <- first (atPath dest) (classifySeed (Map.lookup dest (manifest ^. #seeds)) (Map.lookup dest (manifest ^. #files)) observed)+                Right (PlannedSeed dest content strategy moduleName (Map.findWithDefault (Set.singleton moduleName) dest claims) observed decision)+    _ -> error "non-seed operation in seed partition"+  pure $ case [err | Left err <- results] of [] -> Right [planned | Right planned <- results]; errors -> Left errors++ancestors :: FilePath -> [FilePath]+ancestors path = reverse (go (P.takeDirectory path))+  where+    go "." = []; go "" = []; go p = p : go (P.takeDirectory p)++atPath :: FilePath -> SeedError -> SeedError+atPath path err = err & #message %~ (("seed '" <> T.pack path <> "': ") <>)++checkSpelling :: (Filesystem :> es) => FilePath -> FilePath -> PathEntry -> Eff es (Either SeedError ())+checkSpelling _ _ EntryMissing = pure (Right ())+checkSpelling root path _ = do+  names <- listDirectory (root </> P.takeDirectory path)+  pure $ if P.takeFileName path `elem` names then Right () else Left (SeedError "seed_native_alias" ("seed path '" <> T.pack path <> "' resolves through a different native spelling"))++-- | Never traverse an unsafe ancestor. Missing parents are created only for an+-- actual exclusive creation attempt, so respecting deletion creates no directories.+checkParents :: (Filesystem :> es) => FilePath -> FilePath -> Bool -> Eff es (Either SeedError ())+checkParents root dest create = go (ancestors dest)+  where+    go [] = pure (Right ())+    go (parent : rest) =+      inspectPath (root </> parent) >>= \case+        EntryDirectory -> do+          spelling <- checkSpelling root parent EntryDirectory+          case spelling of Left err -> pure (Left err); Right () -> go rest+        EntryMissing | create -> createDirectoryIfMissing False (root </> parent) >> go rest+        EntryMissing -> pure (Right ())+        _ -> pure (Left (SeedError "seed_parent_unsafe" ("seed ancestor '" <> T.pack parent <> "' must be a real directory; move the object aside")))++-- | Return the application's complete actual candidate claims; the caller uses+-- replaceSelectedSeedClaims after attaching final application records. No receipt+-- is returned for a failed creation. A partial run may leave a complete new file.+executeSeeds :: (Filesystem :> es) => FilePath -> ApplicationId -> UTCTime -> [PlannedSeed] -> Manifest -> Eff es (Either SeedError (Map FilePath SeedRecord))+executeSeeds root app now plans manifest = foldM apply (Right Map.empty) plans+  where+    apply (Left err) _ = pure (Left err)+    apply (Right records) planned = do+      let path = planned ^. #dest+          decision = planned ^. #decision+      outcome <- case decision of+        SeedWrite -> do+          safe <- checkParents root path True+          case safe of+            Left err -> pure (Left err)+            Right () ->+              createFileExclusive (root </> path) (planned ^. #content) >>= \case+                ExclusiveCreated -> pure (Right SeedCreated)+                ExclusiveFailure reason -> pure (Left (SeedError "seed_creation_failed" ("cannot create seed '" <> T.pack path <> "': " <> reason)))+                ExclusiveOccupied ->+                  inspectPath (root </> path) >>= \case+                    EntryFile -> pure (Right SeedFoundExisting)+                    _ -> pure (Left (SeedError "seed_destination_changed" ("seed '" <> T.pack path <> "' became occupied; move it aside and retry")))+        SeedReleaseManaged -> pure (Left (SeedError "seed_lifecycle_transition" "managed release requires seihou update"))+        SeedKeepExisting -> pure (Right SeedFoundExisting)+        _ -> pure (Right (maybe SeedFoundExisting (^. #outcome) (Map.lookup path (manifest ^. #seeds))))+      pure $ do+        actual <- outcome+        let prior = Map.lookup path (manifest ^. #seeds)+            record = SeedRecord (maybe (planned ^. #supplier) (^. #moduleName) prior) (KnownSeedOwners (Map.singleton app (planned ^. #owners))) actual (maybe now (^. #settledAt) prior)+        Right (Map.insert path record records)++renderSeedPlan :: Bool -> [PlannedSeed] -> Text+renderSeedPlan showContent plans = T.concat (map render plans)+  where+    render planned =+      "  "+        <> action (planned ^. #decision)+        <> " "+        <> T.pack (planned ^. #dest)+        <> " (seed; owned by the project)\n"+        <> if showContent && planned ^. #decision == SeedWrite then T.unlines (map ("    + " <>) (T.lines (planned ^. #content))) else ""+    action SeedWrite = "create once"+    action SeedKeepExisting = "keep existing"+    action SeedRespectDeletion = "keep deleted"+    action SeedAlreadySettled = "keep settled"+    action SeedReleaseManaged = "release to project"
src/Seihou/Engine/UpdateTransaction.hs view
@@ -2,6 +2,7 @@   ( UpdateTransaction (..),     TransactionError (..),     beginUpdateTransaction,+    beginSeedUpdateTransaction,     applyReconciliation,     applyReconciliationWithHook,     rollbackUpdateTransaction,@@ -18,6 +19,7 @@ import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=)) import Data.Aeson qualified as Aeson import Data.Aeson.Types qualified as Aeson+import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS import Data.Foldable (traverse_) import Data.Generics.Labels ()@@ -25,17 +27,21 @@ import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text qualified as T+import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as TIO import Data.Time (UTCTime, getCurrentTime) import Seihou.Core.Path (validateProjectRelativePath)+import Seihou.Core.Seed (renderSeedOwnershipError, replaceSelectedSeedClaims) import Seihou.Core.Types import Seihou.Effect.BaselineStore (putBaseline) import Seihou.Effect.BaselineStoreInterp (runBaselineStore)-import Seihou.Effect.Filesystem (PathEntry (..), inspectPath)+import Seihou.Effect.Filesystem (ExclusiveCreateResult (..), PathEntry (..), inspectPath) import Seihou.Effect.FilesystemInterp (runFilesystem) import Seihou.Effect.Process (runProcess) import Seihou.Effect.ProcessInterp (runProcessIO) import Seihou.Engine.Reconcile+import Seihou.Engine.Seed (SeedDecision (..))+import Seihou.Filesystem.Exclusive (createFileExclusiveAnchored) import Seihou.Manifest.Hash (hashContent) import Seihou.Manifest.Types (manifestFromJSON) import Seihou.Prelude@@ -44,6 +50,7 @@ import System.FilePath (splitDirectories, takeDirectory, takeFileName) import System.IO (hClose, hSetEncoding, utf8) import System.IO.Temp (createTempDirectory, openTempFile)+import System.Posix.Files qualified as Posix  data UpdateTransaction = UpdateTransaction   { projectRoot :: !FilePath,@@ -77,6 +84,7 @@     entries :: ![JournalEntry],     newDirectories :: ![FilePath],     indexLinks :: !(Map FilePath Text),+    seedCreations :: !(Map FilePath (Maybe (SHA256, Integer, Integer))),     expectedManifest :: !(Maybe Manifest)   }   deriving stock (Eq, Generic, Show)@@ -118,23 +126,28 @@         "entries" .= (metadata ^. #entries),         "newDirectories" .= (metadata ^. #newDirectories),         "indexLinks" .= (metadata ^. #indexLinks),+        "seedCreations" .= (metadata ^. #seedCreations),         "expectedManifest" .= (metadata ^. #expectedManifest)       ]  instance FromJSON JournalMetadata where   parseJSON = Aeson.withObject "JournalMetadata" $ \object -> do     version <- object .: "version"-    unless (version `elem` [1, 2, 3]) (fail "unsupported transaction journal version")+    unless (version `elem` [1, 2, 3, 4]) (fail "unsupported transaction journal version")     JournalMetadata       <$> pure version       <*> object .: "createdAt"       <*> (mapM (parseJournalEntry version) =<< object .: "entries")       <*> object .: "newDirectories"       <*> (if version < 3 then pure Map.empty else object .: "indexLinks")+      <*> (if version < 4 then pure Map.empty else object .: "seedCreations")       <*> object .:? "expectedManifest"  beginUpdateTransaction :: FilePath -> Set FilePath -> IO (Either TransactionError UpdateTransaction)-beginUpdateTransaction projectRoot targets = case traverse validateTransactionPath (Set.toAscList targets) of+beginUpdateTransaction root targets = beginSeedUpdateTransaction root targets Set.empty++beginSeedUpdateTransaction :: FilePath -> Set FilePath -> Set FilePath -> IO (Either TransactionError UpdateTransaction)+beginSeedUpdateTransaction projectRoot targets seedPaths = case traverse validateTransactionPath (Set.toAscList targets) of   Left err -> pure (Left err)   Right safeTargets -> do     result <- try @SomeException $ do@@ -142,15 +155,15 @@       Directory.createDirectoryIfMissing True transactionsRoot       transactionDirectory <- createTempDirectory transactionsRoot "update-"       let transaction = UpdateTransaction projectRoot transactionDirectory (Set.fromList safeTargets)-      initializeJournal transaction safeTargets `onException` cleanupDirectory transactionDirectory+      initializeJournal transaction seedPaths safeTargets `onException` cleanupDirectory transactionDirectory       pure transaction     pure $ first (TransactionStartFailed . exceptionText) result -initializeJournal :: UpdateTransaction -> [FilePath] -> IO ()-initializeJournal transaction safeTargets = do+initializeJournal :: UpdateTransaction -> Set FilePath -> [FilePath] -> IO ()+initializeJournal transaction seedPaths safeTargets = do   let backupDirectory = transaction ^. #transactionDirectory </> "backups"   Directory.createDirectoryIfMissing True backupDirectory-  entries <- forM (zip [0 :: Int ..] safeTargets) $ \(index, relativePath) -> do+  entries <- forM (zip [0 :: Int ..] (filter (`Set.notMember` seedPaths) safeTargets)) $ \(index, relativePath) -> do     let fullPath = transaction ^. #projectRoot </> relativePath         backupName = show index <> ".txt"         backupPath = backupDirectory </> backupName@@ -168,11 +181,12 @@   writeJournal     (transaction ^. #transactionDirectory)     JournalMetadata-      { journalVersion = 2,+      { journalVersion = if Set.null seedPaths then 2 else 4,         createdAt = now,         entries = entries,         newDirectories = missingDirectories,         indexLinks = Map.empty,+        seedCreations = Map.empty,         expectedManifest = Nothing       } @@ -221,7 +235,7 @@       actual <- trackedLinkIdentity (transaction ^. #projectRoot) path       unless (actual == Just oid) (ioError (userError ("Git index changed for " <> path)))     metadata <- readJournal (transaction ^. #transactionDirectory) >>= either (ioError . userError . show) pure-    writeJournal (transaction ^. #transactionDirectory) (metadata & #journalVersion .~ 3 & #indexLinks .~ links)+    writeJournal (transaction ^. #transactionDirectory) (metadata & #journalVersion %~ max 3 & #indexLinks .~ links)     removed <- gitOutput (transaction ^. #projectRoot) (["update-index", "--force-remove", "--"] <> map T.pack (Map.keys links))     when (removed == Nothing) (ioError (userError "could not adopt tracked links in Git's index"))   pure $ first (\err -> TransactionApplyFailed (exceptionText err) Nothing) result@@ -293,31 +307,46 @@     | not (Set.null unresolved) -> pure (Left (TransactionUnresolvedPaths unresolved))     | otherwise -> verifyObservedFiles transaction plan   where-    unjournaled = Map.keysSet (plan ^. #files) Set.\\ (transaction ^. #targets)+    unjournaled = reconciliationMutationPaths plan Set.\\ (transaction ^. #targets)     unresolved = unresolvedPaths plan  verifyObservedFiles :: UpdateTransaction -> ReconciliationPlan -> IO (Either TransactionError ()) verifyObservedFiles transaction plan = go (Map.toAscList (plan ^. #files))   where     go [] = pure (Right ())+    go ((_, FileSeedSettle {}) : rest) = go rest+    go ((_, FileSeedRelease {}) : rest) = go rest+    go ((_, FileSeedAbandon {}) : rest) = go rest+    go ((path, FileSeedCreate _ _) : rest) = do+      entry <- runEff $ runFilesystem $ inspectPath (transaction ^. #projectRoot </> path)+      if entry == EntryMissing+        then go rest+        else do+          clearPendingSeed transaction path+          pure (Left (TransactionApplyFailed ("seed path became occupied after preview: " <> T.pack path <> "; rerun update to review it") Nothing))     go ((path, reconciliation) : rest) = do       current <- observeDiskFile (transaction ^. #projectRoot </> path)       let planned = reconciliationObservation reconciliation-      if current == planned-        then go rest-        else pure (Left (TransactionStalePlan path planned current))+      case planned of+        Nothing -> go rest+        Just expected | current == expected -> go rest+        Just expected -> pure (Left (TransactionStalePlan path expected current)) -reconciliationObservation :: FileReconciliation -> ObservedFile+reconciliationObservation :: FileReconciliation -> Maybe ObservedFile reconciliationObservation reconciliation = case reconciliation of-  FileCreate _ _ observed -> observed-  FileUpdate _ _ observed _ -> observed-  FileAutoMerge _ _ observed _ -> observed-  FileUnchanged _ _ observed _ -> observed-  FileConflict _ _ _ _ observed _ _ -> observed-  FileDeleteSafe _ _ observed -> observed-  FileOrphanEdited _ _ _ observed _ -> observed-  FileReleaseSharedOwnership _ _ observed -> observed-  FileAlreadyAbsent _ _ observed -> observed+  FileSeedCreate {} -> Nothing+  FileSeedSettle {} -> Nothing+  FileSeedRelease {} -> Nothing+  FileSeedAbandon {} -> Nothing+  FileCreate _ _ observed -> Just observed+  FileUpdate _ _ observed _ -> Just observed+  FileAutoMerge _ _ observed _ -> Just observed+  FileUnchanged _ _ observed _ -> Just observed+  FileConflict _ _ _ _ observed _ _ -> Just observed+  FileDeleteSafe _ _ observed -> Just observed+  FileOrphanEdited _ _ _ observed _ -> Just observed+  FileReleaseSharedOwnership _ _ observed -> Just observed+  FileAlreadyAbsent _ _ observed -> Just observed  observeDiskFile :: FilePath -> IO ObservedFile observeDiskFile path = do@@ -339,7 +368,11 @@ prepareCandidateManifest :: UpdateTransaction -> ReconciliationPlan -> Manifest -> IO Manifest prepareCandidateManifest transaction plan manifest = do   nextFiles <- foldM applyManifestAction (manifest ^. #files) (Map.toAscList (plan ^. #files))-  pure (replaceManifestFiles manifest nextFiles)+  let withFiles = replaceManifestFiles manifest nextFiles+      candidates = seedCandidateReceipts (manifest ^. #genAt) manifest plan+  case replaceSelectedSeedClaims (plan ^. #applicationIds) candidates withFiles of+    Left err -> ioError (userError (T.unpack (renderSeedOwnershipError err)))+    Right candidate -> pure candidate   where     selected = plan ^. #applicationIds     applyManifestAction files (path, reconciliation) = case desiredState reconciliation of@@ -390,6 +423,7 @@   Map FilePath FileRecord ->   Map FilePath FileRecord applyOrphanManifestAction selected reconciliation files = case reconciliation of+  FileSeedRelease action _ -> Map.delete (action ^. #path) files   FileDeleteSafe path _ _ -> Map.delete path files   FileAlreadyAbsent path _ _ -> Map.delete path files   FileReleaseSharedOwnership path record _ ->@@ -467,6 +501,7 @@         sortOn pathDepth . Set.toList $           Set.fromList (metadata ^. #newDirectories <> additionalDirectories),       indexLinks = metadata ^. #indexLinks,+      seedCreations = metadata ^. #seedCreations,       expectedManifest = Just candidate     } @@ -479,6 +514,19 @@   where     applyOne count (path, reconciliation) = case mutationFor reconciliation of       NoMutation -> pure count+      CreateSeedMutation content -> do+        let fullPath = transaction ^. #projectRoot </> path+        verifySafeParents (transaction ^. #projectRoot) path+        markPendingSeed transaction path+        (result, identity) <- createFileExclusiveAnchored (Just (transaction ^. #transactionDirectory </> ("seed-" <> T.unpack (hashContent (T.pack path) ^. #unSHA256)))) (\handle -> BS.hPut handle (TE.encodeUtf8 content)) fullPath+        case (result, identity) of+          (ExclusiveCreated, Just (device, inode)) -> recordCreatedSeed transaction path (hashContent content, device, inode)+          (ExclusiveOccupied, _) -> clearPendingSeed transaction path >> ioError (userError ("seed path became occupied after preview: " <> path <> "; rerun update"))+          (ExclusiveFailure reason, _) -> clearPendingSeed transaction path >> ioError (userError (T.unpack reason))+          _ -> ioError (userError "exclusive creation returned no ownership evidence")+        let next = count + 1+        afterMutation next+        pure next       WriteMutation content -> do         atomicWriteText (transaction ^. #projectRoot </> path) content         let next = count + 1@@ -492,9 +540,10 @@         afterMutation next         pure next -data FileMutation = NoMutation | WriteMutation Text | DeleteMutation+data FileMutation = NoMutation | WriteMutation Text | CreateSeedMutation Text | DeleteMutation  mutationFor :: FileReconciliation -> FileMutation+mutationFor (FileSeedCreate action _) = maybe NoMutation CreateSeedMutation (action ^. #newContent) mutationFor reconciliation = case desiredState reconciliation of   Just (_, state)     | state ^. #writeToDisk -> WriteMutation (state ^. #appliedContent)@@ -519,6 +568,23 @@           unless             (Just current == (EntrySymlink <$> entry ^. #linkTarget))             (ioError (userError ("adopted working link changed during recovery: " <> entry ^. #targetPath)))+        -- Only transaction-owned creations may be removed. A crash before+        -- completion evidence leaves uncertainty explicit and preserves bytes.+        forM_ (Map.toAscList (metadata ^. #seedCreations)) $ \(path, expected) -> do+          verifySafeParents (transaction ^. #projectRoot) path+          let fullPath = transaction ^. #projectRoot </> path+          entry <- runEff $ runFilesystem $ inspectPath fullPath+          case (entry, expected) of+            (EntryMissing, _) -> pure ()+            (EntryFile, Just (digest, device, inode)) -> do+              status <- Posix.getSymbolicLinkStatus fullPath+              let changed = ioError (userError ("created seed changed during recovery: " <> path <> "; preserve it outside the destination and retry update --dry-run"))+              if fromIntegral (Posix.deviceID status) /= device || fromIntegral (Posix.fileID status) /= inode+                then changed+                else do+                  content <- TIO.readFile fullPath+                  if hashContent content == digest then Directory.removeFile fullPath else changed+            _ -> ioError (userError ("seed creation outcome is uncertain: " <> path <> "; preserve the object outside the destination and retry update --dry-run"))         restoreIndexLinks (transaction ^. #projectRoot) (metadata ^. #indexLinks)         forM_ (filter (\entry -> Map.notMember (entry ^. #targetPath) (metadata ^. #indexLinks)) (metadata ^. #entries)) (restoreEntry transaction)         removeNewDirectories (transaction ^. #projectRoot) (metadata ^. #newDirectories)@@ -595,6 +661,7 @@                 entries = metadata ^. #entries,                 newDirectories = metadata ^. #newDirectories,                 indexLinks = metadata ^. #indexLinks,+                seedCreations = metadata ^. #seedCreations,                 expectedManifest = Just expected               }       result <- try @SomeException (writeJournal (transaction ^. #transactionDirectory) updated)@@ -672,6 +739,8 @@  validateJournal :: JournalMetadata -> Either Text JournalMetadata validateJournal metadata = do+  unless (Set.null (Map.keysSet (metadata ^. #seedCreations) `Set.intersection` Set.fromList (map (^. #targetPath) (metadata ^. #entries)))) (Left "seed creations must not also have restoring snapshots")+  traverse_ (first renderTransactionPathError . validateTransactionPath) (Map.keys (metadata ^. #seedCreations))   traverse_ (first renderTransactionPathError . validateTransactionPath) (Map.keys (metadata ^. #indexLinks))   traverse_ validateObjectId (Map.elems (metadata ^. #indexLinks))   unless@@ -773,3 +842,20 @@  exceptionText :: SomeException -> Text exceptionText = T.pack . displayException++-- Completion evidence is temporary recovery state, never a seed receipt hash.+recordCreatedSeed :: UpdateTransaction -> FilePath -> (SHA256, Integer, Integer) -> IO ()+recordCreatedSeed transaction path digest = do+  metadata <- readJournal (transaction ^. #transactionDirectory) >>= either (ioError . userError . show) pure+  writeJournal (transaction ^. #transactionDirectory) (metadata & #journalVersion %~ max 4 & #seedCreations %~ Map.insert path (Just digest))++-- Explicitly failed exclusive publication owns no object at this path.+clearPendingSeed :: UpdateTransaction -> FilePath -> IO ()+clearPendingSeed transaction path = do+  metadata <- readJournal (transaction ^. #transactionDirectory) >>= either (ioError . userError . show) pure+  writeJournal (transaction ^. #transactionDirectory) (metadata & #seedCreations %~ Map.delete path)++markPendingSeed :: UpdateTransaction -> FilePath -> IO ()+markPendingSeed transaction path = do+  metadata <- readJournal (transaction ^. #transactionDirectory) >>= either (ioError . userError . show) pure+  writeJournal (transaction ^. #transactionDirectory) (metadata & #journalVersion %~ max 4 & #seedCreations %~ Map.insert path Nothing)
+ src/Seihou/Filesystem/Exclusive.hs view
@@ -0,0 +1,60 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++-- | Native complete-file publication with no replacement. Kept independent of+-- the effect stack so the same implementation can be checked on each host OS.+module Seihou.Filesystem.Exclusive+  ( ExclusiveCreateResult (..),+    createFileExclusiveWith,+    createFileExclusiveWithIdentity,+    createFileExclusiveAnchored,+  )+where++import Control.Exception (IOException, bracket, catch, try)+import Data.Text (Text)+import Data.Text qualified as T+import GHC.Generics (Generic)+import System.Directory (removeFile)+import System.FilePath (takeDirectory)+import System.IO (Handle, hClose, openBinaryTempFile)+import System.IO.Error (isAlreadyExistsError)+import System.Posix.Files qualified as Posix++data ExclusiveCreateResult = ExclusiveCreated | ExclusiveOccupied | ExclusiveFailure Text+  deriving stock (Eq, Show, Generic)++-- | The callback writes only to a private temporary file on the same filesystem.+-- link(2) publishes all bytes at once and fails for every existing entry, including+-- dangling symlinks. Cleanup never removes the public destination.+createFileExclusiveWith :: (Handle -> IO ()) -> FilePath -> IO ExclusiveCreateResult+createFileExclusiveWith writeContent path = fst <$> createFileExclusiveWithIdentity writeContent path++-- | Device/inode evidence comes from the private object, never a later lookup+-- of the public path, so recovery can distinguish an identical replacement.+createFileExclusiveWithIdentity :: (Handle -> IO ()) -> FilePath -> IO (ExclusiveCreateResult, Maybe (Integer, Integer))+createFileExclusiveWithIdentity = createFileExclusiveAnchored Nothing++-- | A transaction may retain a private hard-link anchor until commit/recovery.+-- Keeping the original inode alive prevents inode reuse from making an identical+-- replacement look like this transaction's object.+createFileExclusiveAnchored :: Maybe FilePath -> (Handle -> IO ()) -> FilePath -> IO (ExclusiveCreateResult, Maybe (Integer, Integer))+createFileExclusiveAnchored anchor writeContent path = do+  result <-+    try @IOException $+      bracket+        (openBinaryTempFile (takeDirectory path) ".seihou-seed")+        (\(temporary, handle) -> hClose handle >> (removeFile temporary `catch` \(_ :: IOException) -> pure ()))+        ( \(temporary, handle) -> do+            writeContent handle+            hClose handle+            status <- Posix.getFileStatus temporary+            maybe (pure ()) (Posix.createLink temporary) anchor+            Posix.createLink temporary path+            pure (fromIntegral (Posix.deviceID status), fromIntegral (Posix.fileID status))+        )+  pure $ case result of+    Right identity -> (ExclusiveCreated, Just identity)+    Left err | isAlreadyExistsError err -> (ExclusiveOccupied, Nothing)+    Left err -> (ExclusiveFailure (T.pack (show err)), Nothing)
src/Seihou/Manifest/Types.hs view
@@ -16,7 +16,7 @@   ) where -import Control.Monad (unless)+import Control.Monad (forM_, unless, when) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key@@ -29,9 +29,12 @@ import Data.Set qualified as Set import Data.Text qualified as T import Data.Time (UTCTime)+import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName) import Seihou.Core.ArtifactIdentity (sameArtifactIdentity) import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutRecords)-import Seihou.Core.Seed (ManifestInvariantError (..), validateManifestInvariants)+import Seihou.Core.CommandFingerprint (checkoutCommandOperation, fingerprintCommand)+import Seihou.Core.Path (validateCanonicalProjectPath)+import Seihou.Core.Seed (renderManifestInvariantError, seedApplicationIds, validateManifestInvariants) import Seihou.Core.Types import Seihou.Manifest.Hash (baselineRefFromText) import Seihou.Manifest.JSON (decodeManifestValue)@@ -77,7 +80,7 @@ -- change advances this number is -- docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md. currentManifestVersion :: ManifestSchemaVersion-currentManifestVersion = ManifestSchemaVersion 9+currentManifestVersion = ManifestSchemaVersion 11  -- | The oldest schema the ordinary decoder reads. Anything older records -- machine-specific absolute paths and must go through the explicit@@ -91,7 +94,9 @@ minimumManifestVersion :: ManifestCapability -> ManifestSchemaVersion minimumManifestVersion TargetedAdditiveSharedPathUpdate = ManifestSchemaVersion 7 minimumManifestVersion SeedReceipts = ManifestSchemaVersion 8+minimumManifestVersion SeedOwnerClaims = ManifestSchemaVersion 11 minimumManifestVersion CheckoutLocalOutputs = ManifestSchemaVersion 9+minimumManifestVersion CheckoutLocalCommands = ManifestSchemaVersion 10  -- | Whether a manifest's schema can express everything a capability needs. -- A decoded schema-6 manifest answers 'False' for@@ -215,11 +220,11 @@         "modules" .= (m ^. #modules),         "variables" .= varsToJSON (m ^. #vars),         "files" .= filesToJSON (m ^. #version) (m ^. #files),-        "applications" .= (m ^. #applications),+        "applications" .= map (applicationToJSON (m ^. #version)) (m ^. #applications),         "blueprintMigrations" .= (m ^. #blueprintMigrations)       ]         ++ ["checkoutOutputs" .= Map.map checkoutToJSON (m ^. #checkoutOutputs) | m ^. #version >= ManifestSchemaVersion 9]-        ++ ["seeds" .= Map.map seedToJSON (m ^. #seeds) | m ^. #version >= ManifestSchemaVersion 8]+        ++ ["seeds" .= Map.map (seedToJSON (m ^. #version)) (m ^. #seeds) | m ^. #version >= ManifestSchemaVersion 8]         ++ maybe [] (\r -> ["recipe" .= r]) (m ^. #recipe)         ++ maybe [] (\b -> ["blueprint" .= b]) (m ^. #blueprint) @@ -236,17 +241,17 @@         <*> o .: "modules"         <*> (varsFromJSON =<< o .: "variables")         <*> (filesFromJSON v =<< o .: "files")-        <*> (if v >= ManifestSchemaVersion 8 then Map.traverseWithKey (\_ -> parseSeed) =<< o .: "seeds" else pure Map.empty)+        <*> (if v >= ManifestSchemaVersion 8 then Map.traverseWithKey (\_ -> parseSeed v) =<< o .: "seeds" else pure Map.empty)         <*> (if v >= ManifestSchemaVersion 9 then Map.traverseWithKey (\_ -> parseCheckout) =<< o .: "checkoutOutputs" else pure Map.empty)-        <*> o Aeson..:? "applications" Aeson..!= []+        <*> (o Aeson..:? "applications" Aeson..!= [] >>= mapM (parseApplication v))         <*> o Aeson..:? "recipe"         <*> o Aeson..:? "blueprint"         <*> o Aeson..:? "blueprintMigrations" Aeson..!= []     case validateManifestInvariants manifest of-      Left (PathBothManagedAndSeeded path) -> fail ("path " <> path <> " is recorded both as a tracked file and as a seed")+      Left err -> fail (T.unpack (renderManifestInvariantError err))       Right () -> case validateCheckoutRecords manifest [] of         Left err -> fail (T.unpack (err ^. #message))-        Right () -> pure manifest+        Right () -> validateCheckoutCommands manifest >> pure manifest  -- | Reject a manifest this build cannot read, naming the remedy. --@@ -376,38 +381,53 @@       <*> (varsFromJSON =<< o Aeson..:? "resolvedVars" Aeson..!= Aeson.object [])  instance ToJSON AppliedComposition where-  toJSON composition =-    Aeson.object $-      [ "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)-    where-      commandReceiptsField receipts-        | Map.null receipts = []-        | otherwise = ["commandReceipts" .= commandReceiptsToJSON receipts]+  toJSON = applicationToJSON currentManifestVersion +applicationToJSON :: ManifestSchemaVersion -> AppliedComposition -> Aeson.Value+applicationToJSON schema composition =+  Aeson.object $+    [ "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)+      ++ ["checkoutCommands" .= map checkoutCommandToJSON (composition ^. #checkoutCommands) | schema >= ManifestSchemaVersion 10]+      ++ commandReceiptsField (composition ^. #commandReceipts)+  where+    commandReceiptsField receipts+      | Map.null receipts = []+      | otherwise = ["commandReceipts" .= commandReceiptsToJSON receipts]+ instance FromJSON AppliedComposition where-  parseJSON = Aeson.withObject "AppliedComposition" $ \o ->-    AppliedComposition-      <$> (ApplicationId <$> o .: "applicationId")-      <*> o .: "target"-      <*> o .: "targetOrigin"-      <*> o Aeson..:? "targetVersion"-      <*> (map ModuleName <$> o Aeson..:? "additionalModules" Aeson..!= [])-      <*> o Aeson..:? "namespace"-      <*> o Aeson..:? "context"-      <*> o Aeson..:? "instances" Aeson..!= []-      <*> (commandReceiptsFromJSON =<< o Aeson..:? "commandReceipts" Aeson..!= Aeson.object [])-      <*> o .: "appliedAt"+  parseJSON = parseApplication currentManifestVersion +parseApplication :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Parser AppliedComposition+parseApplication schema = Aeson.withObject "AppliedComposition" $ \o -> do+  commands <-+    if schema >= ManifestSchemaVersion 10+      then o .: "checkoutCommands" >>= mapM parseCheckoutCommand+      else do+        legacy <- o Aeson..:? "checkoutCommands" Aeson..!= ([] :: [Aeson.Value])+        unless (null legacy) (fail "checkout commands require manifest schema 10")+        pure []+  AppliedComposition+    <$> (ApplicationId <$> o .: "applicationId")+    <*> o .: "target"+    <*> o .: "targetOrigin"+    <*> o Aeson..:? "targetVersion"+    <*> (map ModuleName <$> o Aeson..:? "additionalModules" Aeson..!= [])+    <*> o Aeson..:? "namespace"+    <*> o Aeson..:? "context"+    <*> o Aeson..:? "instances" Aeson..!= []+    <*> (commandReceiptsFromJSON =<< o Aeson..:? "commandReceipts" Aeson..!= Aeson.object [])+    <*> pure commands+    <*> o .: "appliedAt"+ instance ToJSON CommandReceipt where   toJSON receipt =     Aeson.object $@@ -618,11 +638,13 @@ parseRemovalActionText other = fail ("unknown removal action: " <> T.unpack other)  parseRemovalCommandJSON :: Aeson.Value -> Aeson.Parser Command-parseRemovalCommandJSON = Aeson.withObject "RemovalCommand" $ \o ->+parseRemovalCommandJSON = Aeson.withObject "RemovalCommand" $ \o -> do+  unless (all (`elem` ["run", "workDir"]) (KM.keys o)) (fail "removal commands only support application execution")   Command     <$> o .: "run"     <*> o Aeson..:? "workDir"     <*> pure Nothing+    <*> pure ApplicationCommand  -- | Encode a file record in the representation of the given schema. --@@ -759,22 +781,38 @@ strategyFromText "symlink" = fail "checkout symlinks cannot be managed file records" strategyFromText other = fail ("unknown strategy: " <> T.unpack other) -seedToJSON :: SeedRecord -> Aeson.Value-seedToJSON record =-  Aeson.object+seedToJSON :: ManifestSchemaVersion -> SeedRecord -> Aeson.Value+seedToJSON version record =+  Aeson.object $     [ "module" .= (record ^. #moduleName . #unModuleName),-      "applications" .= map (^. #unApplicationId) (Set.toAscList (record ^. #applicationIds)),       "outcome" .= seedOutcomeToText (record ^. #outcome),       "settledAt" .= (record ^. #settledAt)     ]+      <> if version < minimumManifestVersion SeedOwnerClaims+        then ["applications" .= map (^. #unApplicationId) (Set.toAscList (seedApplicationIds record))]+        else+          [ "ownership" .= case record ^. #ownership of+              LegacySeedOwners apps ->+                Aeson.object+                  ["kind" .= ("legacy-applications" :: Text), "applications" .= map (^. #unApplicationId) (Set.toAscList apps)]+              KnownSeedOwners owners ->+                Aeson.object+                  ["kind" .= ("instances" :: Text), "owners" .= Map.mapKeys (^. #unApplicationId) (Map.map (map (^. #unModuleName) . Set.toAscList) owners)]+          ]  seedOutcomeToText :: SeedOutcome -> Text seedOutcomeToText SeedCreated = "created" seedOutcomeToText SeedFoundExisting = "found-existing" seedOutcomeToText SeedReleasedFromManaged = "released-from-managed" -parseSeed :: Aeson.Value -> Aeson.Parser SeedRecord-parseSeed = Aeson.withObject "seed receipt" $ \o -> do+parseSeed :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Parser SeedRecord+parseSeed version = Aeson.withObject "seed receipt" $ \o -> do+  ownership <-+    if version < minimumManifestVersion SeedOwnerClaims+      then LegacySeedOwners . Set.fromList . map ApplicationId <$> o .: "applications"+      else do+        when (KM.member "applications" o) (fail "seed receipt must not duplicate ownership with applications")+        o .: "ownership" >>= parseSeedOwnership   outcome <-     o .: "outcome" >>= \case       "created" -> pure SeedCreated@@ -783,14 +821,33 @@       (other :: Text) -> fail ("unknown seed outcome: " <> T.unpack other <> "; expected created, found-existing, released-from-managed")   SeedRecord     <$> (ModuleName <$> o .: "module")-    <*> (Set.fromList . map ApplicationId <$> o .: "applications")+    <*> pure ownership     <*> pure outcome     <*> o .: "settledAt" +parseSeedOwnership :: Aeson.Value -> Aeson.Parser SeedOwnership+parseSeedOwnership = Aeson.withObject "seed ownership" $ \o -> do+  kind <- o .: "kind" :: Aeson.Parser Text+  case kind of+    "legacy-applications" -> do+      unless (Set.fromList (KM.keys o) == Set.fromList ["kind", "applications"]) (fail "legacy seed ownership requires exactly kind and applications")+      LegacySeedOwners . Set.fromList . map ApplicationId <$> o .: "applications"+    "instances" -> do+      unless (Set.fromList (KM.keys o) == Set.fromList ["kind", "owners"]) (fail "seed instance ownership requires exactly kind and owners")+      raw <- o .: "owners" :: Aeson.Parser (Map Text [Text])+      forM_ (Map.elems raw) $ \names -> unless (length names == Set.size (Set.fromList names)) (fail "duplicate seed instance owner")+      pure (KnownSeedOwners (Map.mapKeys ApplicationId (Map.map (Set.fromList . map ModuleName) raw)))+    other -> fail ("unknown seed ownership kind: " <> T.unpack other)+ -- | Facts that would be lost if a caller encoded a value at its stated schema. manifestEncodingLoss :: Manifest -> [Text] manifestEncodingLoss manifest =-  ["checkout outputs require manifest schema 9" | manifest ^. #version < ManifestSchemaVersion 9, not (Map.null (manifest ^. #checkoutOutputs))]+  either (pure . renderManifestInvariantError) (const []) (validateManifestInvariants manifest)+    <> ["seed owner claims require manifest schema 11" | not (manifestSupports SeedOwnerClaims manifest), any (\r -> case r ^. #ownership of KnownSeedOwners _ -> True; _ -> False) (Map.elems (manifest ^. #seeds))]+    <> either (pure . T.pack) (const []) (Aeson.parseEither (const (validateCheckoutCommands manifest)) Aeson.Null)+    <> ["removal commands cannot declare checkout setup" | applied <- manifest ^. #modules, removal <- maybe [] pure (applied ^. #removal), any ((== CheckoutCommandLifecycle) . (^. #lifecycle)) (removal ^. #commands)]+    <> ["checkout commands require manifest schema 10" | manifest ^. #version < ManifestSchemaVersion 10, any (not . null . (^. #checkoutCommands)) (manifest ^. #applications)]+    <> ["checkout outputs require manifest schema 9" | manifest ^. #version < ManifestSchemaVersion 9, not (Map.null (manifest ^. #checkoutOutputs))]     <> ["seed receipts require manifest schema 8" | manifest ^. #version < ManifestSchemaVersion 8, not (Map.null (manifest ^. #seeds))]  checkoutToJSON :: CheckoutOutputRecord -> Aeson.Value@@ -816,3 +873,30 @@         )   owners <- o .: "owners" :: Aeson.Parser (Map Text [Text])   pure (CheckoutOutputRecord spec (Map.mapKeys ApplicationId (Map.map (Set.fromList . map ModuleName) owners)))++checkoutCommandToJSON :: CheckoutCommand -> Aeson.Value+checkoutCommandToJSON CheckoutCommand {moduleName, occurrence, command, workDir} =+  Aeson.object ["kind" .= ("shell" :: Text), "module" .= (moduleName ^. #unModuleName), "occurrence" .= occurrence, "command" .= command, "workDir" .= workDir]++parseCheckoutCommand :: Aeson.Value -> Aeson.Parser CheckoutCommand+parseCheckoutCommand = Aeson.withObject "checkout command" $ \o -> do+  unless (Set.fromList (KM.keys o) == Set.fromList ["kind", "module", "occurrence", "command", "workDir"]) (fail "checkout command requires exactly kind, module, occurrence, command and workDir")+  kind <- o .: "kind" :: Aeson.Parser Text+  unless (kind == "shell") (fail ("unknown checkout command kind: " <> T.unpack kind))+  CheckoutCommand <$> (ModuleName <$> o .: "module") <*> o .: "occurrence" <*> o .: "command" <*> o .: "workDir"++validateCheckoutCommands :: Manifest -> Aeson.Parser ()+validateCheckoutCommands manifest = do+  let applications = manifest ^. #applications+      ids = map (^. #applicationId) applications+  unless (length ids == Set.size (Set.fromList ids)) (fail "duplicate application IDs")+  forM_ applications $ \application -> do+    let commands = application ^. #checkoutCommands+        identities = map (fingerprintCommand . checkoutCommandOperation) commands+        owners = Set.fromList [qualifiedName (ModuleInstance (i ^. #name) (i ^. #parentVars)) | i <- application ^. #instances]+    unless (length identities == Set.size (Set.fromList identities)) (fail "duplicate checkout command identity")+    forM_ commands $ \command -> do+      unless (Set.member (command ^. #moduleName) owners) (fail "unknown checkout command instance owner")+      unless (command ^. #occurrence >= 0 && not (T.null (T.strip (command ^. #command)))) (fail "invalid checkout command occurrence or empty shell text")+      forM_ (command ^. #workDir) $ \path ->+        unless (path == ".") $ either (fail . T.unpack) pure (validateCanonicalProjectPath (T.pack path))
src/Seihou/Manifest/Upgrade.hs view
@@ -33,7 +33,7 @@   ) where -import Control.Monad (foldM, unless)+import Control.Monad (foldM, forM, unless, when) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap@@ -58,6 +58,8 @@     MakeSharedWriteEvidenceExplicit   | AddEmptySeedReceipts   | AddEmptyCheckoutOutputs+  | MakeSeedOwnershipExplicit+  | AddEmptyCheckoutCommands   deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)  -- | Whether a step may be staged automatically.@@ -117,7 +119,9 @@     step 5 ConvertMachineLocalPaths "portable artifact origins",     step 6 MakeSharedWriteEvidenceExplicit "explicit shared-write evidence",     step 7 AddEmptySeedReceipts "seed receipts",-    step 8 AddEmptyCheckoutOutputs "checkout outputs"+    step 8 AddEmptyCheckoutOutputs "checkout outputs",+    step 9 AddEmptyCheckoutCommands "repeatable checkout commands",+    step 10 MakeSeedOwnershipExplicit "explicit seed instance ownership"   ]   where     step from stepAction =@@ -129,6 +133,8 @@ upgradeStepKind MakeSharedWriteEvidenceExplicit = LosslessUpgrade upgradeStepKind AddEmptySeedReceipts = LosslessUpgrade upgradeStepKind AddEmptyCheckoutOutputs = LosslessUpgrade+upgradeStepKind AddEmptyCheckoutCommands = LosslessUpgrade+upgradeStepKind MakeSeedOwnershipExplicit = LosslessUpgrade  -- | The contiguous steps from @source@ to @target@. Planning from a version -- to itself is an empty path.@@ -184,6 +190,20 @@     Left (StepVersionMismatch upgradeStep actual)   transformed <- case upgradeStep ^. #action of     StampVersion -> Right document+    MakeSeedOwnershipExplicit -> first (MalformedDocument actual) (makeSeedOwnershipExplicit document)+    AddEmptyCheckoutCommands -> case document of+      Aeson.Object fields -> do+        applications <- case KeyMap.lookup "applications" fields of+          Nothing -> Right []+          Just value -> first (MalformedDocument actual . T.pack) (Aeson.fromJSON value & \case Aeson.Error err -> Left err; Aeson.Success xs -> Right (xs :: [Aeson.Value]))+        upgraded <- forM applications $ \case+          Aeson.Object app -> case KeyMap.lookup "checkoutCommands" app of+            Nothing -> Right (Aeson.Object (KeyMap.insert "checkoutCommands" (Aeson.toJSON ([] :: [Aeson.Value])) app))+            Just (Aeson.Array xs) | null xs -> Right (Aeson.Object app)+            _ -> Left (MalformedDocument actual "schema 9 cannot contain checkout commands")+          _ -> Left (MalformedDocument actual "application must be an object")+        Right (Aeson.Object (KeyMap.insert "applications" (Aeson.toJSON upgraded) fields))+      _ -> Left (MalformedDocument actual "manifest must be an object")     AddEmptyCheckoutOutputs -> case document of       Aeson.Object fields -> case KeyMap.lookup "checkoutOutputs" fields of         Nothing -> Right (Aeson.Object (KeyMap.insert "checkoutOutputs" (Aeson.object []) fields))@@ -280,3 +300,22 @@   MalformedDocument v reason -> "the schema-" <> showVersion v <> " manifest is malformed: " <> reason   where     showVersion (ManifestSchemaVersion v) = T.pack (show v)++-- | Wrap the old evidence without inventing instance claims. Unknown members+-- survive at every level; malformed old ownership cannot acquire a new stamp.+makeSeedOwnershipExplicit :: Aeson.Value -> Either Text Aeson.Value+makeSeedOwnershipExplicit (Aeson.Object fields) = case KeyMap.lookup "seeds" fields of+  Just (Aeson.Object seeds) -> do+    converted <- KeyMap.traverseWithKey convert seeds+    pure (Aeson.Object (KeyMap.insert "seeds" (Aeson.Object converted) fields))+  _ -> Left "seeds must be an object"+  where+    convert _ (Aeson.Object receipt) = do+      when (KeyMap.member "ownership" receipt) (Left "schema 10 seed cannot contain ownership")+      apps <- case KeyMap.lookup "applications" receipt of+        Just value | Aeson.Success (_ :: [Text]) <- Aeson.fromJSON value -> Right value+        _ -> Left "seed applications must be an array of application IDs"+      let ownership = Aeson.object ["kind" Aeson..= ("legacy-applications" :: Text), "applications" Aeson..= apps]+      pure (Aeson.Object (KeyMap.insert "ownership" ownership (KeyMap.delete "applications" receipt)))+    convert _ _ = Left "seed receipt must be an object"+makeSeedOwnershipExplicit _ = Left "manifest must be an object"
test/Main.hs view
@@ -52,6 +52,8 @@ import Seihou.Engine.ReconcileSpec qualified as ReconcileSpec import Seihou.Engine.RemoveSpec qualified as RemoveSpec import Seihou.Engine.SectionSpec qualified as SectionSpec+import Seihou.Engine.SeedSpec qualified as SeedEngine+import Seihou.Engine.SeedTransactionSpec qualified as SeedTransaction import Seihou.Engine.TemplateSpec qualified as TemplateSpec import Seihou.Engine.ThreeWayMergeSpec qualified as ThreeWayMergeSpec import Seihou.Engine.UpdateTransactionSpec qualified as UpdateTransactionSpec@@ -140,4 +142,6 @@   manifestUpgradeTests <- ManifestUpgradeSpec.tests   promptTests <- PromptSpec.tests   confirmTests <- ConfirmSpec.tests-  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactIdentityTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, entailmentTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, checkoutEngineTests, checkoutTests, seedTests, 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, manifestUpgradeTests, promptTests, confirmTests])+  seedTransactionTests <- SeedTransaction.tests+  seedEngineTests <- SeedEngine.tests+  defaultMain (testGroup "seihou-core" [seedTransactionTests, seedEngineTests, graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactIdentityTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, entailmentTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, checkoutEngineTests, checkoutTests, seedTests, 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, manifestUpgradeTests, promptTests, confirmTests])
test/Seihou/Composition/PlanSpec.hs view
@@ -1,8 +1,13 @@ module Seihou.Composition.PlanSpec (tests) where +import Data.Either (isLeft)+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set import Data.Text qualified as T import Seihou.Composition.Plan import Seihou.Core.Types+import Seihou.Prelude import Test.Hspec import Test.Tasty import Test.Tasty.Hspec (testSpec)@@ -12,18 +17,40 @@  spec :: Spec spec = do+  describe "seed composition" $ do+    it "keeps the first supplier in execution order and every qualified claimant" $ do+      let first = SeedFileOp "start.txt" "first" Copy "z[first]"+          second = SeedFileOp "start.txt" "second" Copy "a[second]"+      Right composed <- pure (mergeOperations [("z[first]", [first]), ("a[second]", [second])])+      composed ^. #operations `shouldBe` [first]+      composed ^. #managedOwners `shouldBe` Map.empty+      composed ^. #seedOwners `shouldBe` Map.singleton "start.txt" (Set.fromList ["z[first]", "a[second]"])+      composed ^. #warnings `shouldBe` [SeedContentIgnored "start.txt" "z[first]" "a[second]"]+    it "refuses mixed lifecycle and ancestor collisions in either order" $ do+      let seed = SeedFileOp "start.txt" "seed" Copy "seed"+          conflicts = [WriteFileOp "start.txt" "managed" Copy, WriteFileOp "START.TXT" "alias" Copy, PatchFileOp "start.txt" "patch" AppendFile Template "patcher", CheckoutOutputOp "start.txt" (DirectorySymlink "config") "checkout", WriteFileOp "start.txt/child" "managed" Copy, SeedFileOp "start.txt/child" "nested" Copy "other"]+      mapM_+        ( \op -> do+            mergeOperations [("seed", [seed]), ("other", [op])] `shouldSatisfy` isLeft+            mergeOperations [("other", [op]), ("seed", [seed])] `shouldSatisfy` isLeft+        )+        conflicts++    it "refuses conflicting parent spellings before creating either seed" $ do+      mergeOperations [("a", [SeedFileOp "Sources/a" "a" Copy "a"]), ("b", [SeedFileOp "sources/b" "b" Copy "b"])] `shouldSatisfy` isLeft+   describe "mergeOperations" $ do     it "preserves all operations when no files overlap" $ do       let aOps = [CreateDirOp "src", WriteFileOp "src/A.hs" "module A" Template]           bOps = [CreateDirOp "lib", WriteFileOp "lib/B.hs" "module B" Template]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       length ops `shouldBe` 4       warnings `shouldBe` []      it "last-writer-wins when two modules write same file" $ do       let aOps = [WriteFileOp "README.md" "from A" Template]           bOps = [WriteFileOp "README.md" "from B" Template]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       -- Only B's version should remain       length (filter isWriteOp ops) `shouldBe` 1       case filter isWriteOp ops of@@ -41,21 +68,21 @@     it "deduplicates CreateDirOp silently" $ do       let aOps = [CreateDirOp "src"]           bOps = [CreateDirOp "src"]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       length ops `shouldBe` 1       warnings `shouldBe` []      it "keeps all RunCommandOp operations" $ do-      let aOps = [RunCommandOp "echo hello" Nothing "module-a" 0]-          bOps = [RunCommandOp "echo world" Nothing "module-b" 0]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+      let aOps = [RunCommandOp "echo hello" Nothing "module-a" 0 ApplicationCommand]+          bOps = [RunCommandOp "echo world" Nothing "module-b" 0 ApplicationCommand]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       length ops `shouldBe` 2       warnings `shouldBe` []      it "handles CopyFileOp overlapping with WriteFileOp" $ do       let aOps = [WriteFileOp "config.yaml" "generated" Template]           bOps = [CopyFileOp "files/config.yaml" "config.yaml"]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       length warnings `shouldBe` 1       -- B's CopyFileOp should be the winner       case filter (\op -> destOfOp' op == Just "config.yaml") ops of@@ -66,7 +93,7 @@       let aOps = [WriteFileOp "README.md" "from A" Template]           bOps = [WriteFileOp "README.md" "from B" Template]           cOps = [WriteFileOp "README.md" "from C" Template]-          (ops, warnings, _) = mergeOperations [("a", aOps), ("b", bOps), ("c", cOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("a", aOps), ("b", bOps), ("c", cOps)]       -- Only C's version should remain       case filter isWriteOp ops of         [WriteFileOp _ content _] -> content `shouldBe` "from C"@@ -75,7 +102,7 @@       length warnings `shouldBe` 2      it "handles empty module operations" $ do-      let (ops, warnings, _) = mergeOperations [("a", []), ("b", [])]+      let Right ComposedPlan {operations = ops, warnings} = mergeOperations [("a", []), ("b", [])]       ops `shouldBe` []       warnings `shouldBe` [] @@ -83,7 +110,7 @@     it "PatchFileOp AppendFile merges with existing WriteFileOp" $ do       let aOps = [WriteFileOp "README.md" "# Title\n" Template]           bOps = [PatchFileOp "README.md" "extra line\n" AppendFile Template "mod-b"]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       -- Should be a single merged WriteFileOp       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do@@ -101,7 +128,7 @@     it "PatchFileOp AppendSection adds section markers and merges" $ do       let aOps = [WriteFileOp "README.md" "# Title\n" Template]           bOps = [PatchFileOp "README.md" "section content\n" AppendSection Template "mod-b"]-          (ops, _, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           T.isInfixOf "# Title" content `shouldBe` True@@ -112,7 +139,7 @@     it "PatchFileOp PrependFile prepends content" $ do       let aOps = [WriteFileOp "README.md" "# Title\n" Template]           bOps = [PatchFileOp "README.md" "header\n" PrependFile Template "mod-b"]-          (ops, _, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           -- header should appear before Title@@ -123,7 +150,7 @@      it "PatchFileOp targeting nonexistent file is preserved as PatchFileOp" $ do       let bOps = [PatchFileOp "new.txt" "content\n" AppendFile Template "mod-b"]-          (ops, _, _) = mergeOperations [("mod-b", bOps)]+          Right ComposedPlan {operations = ops} = mergeOperations [("mod-b", bOps)]       -- PatchFileOp with no existing target is preserved so the execution       -- engine can merge with the on-disk file and the diff engine avoids       -- false conflict classification.@@ -133,7 +160,7 @@       let aOps = [WriteFileOp "README.md" "# Title\n" Template]           bOps = [PatchFileOp "README.md" "from B\n" AppendSection Template "mod-b"]           cOps = [PatchFileOp "README.md" "from C\n" AppendSection Template "mod-c"]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps), ("mod-c", cOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps), ("mod-c", cOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           T.isInfixOf "# Title" content `shouldBe` True@@ -148,14 +175,14 @@     it "ContentMerged warning is generated for patch merge" $ do       let aOps = [WriteFileOp "f.txt" "base\n" Template]           bOps = [PatchFileOp "f.txt" "added\n" AppendFile Template "mod-b"]-          (_, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       any isContentMerged warnings `shouldBe` True       any isFileOverwritten warnings `shouldBe` False      it "PatchFileOp AppendLineIfAbsent deduplicates lines during merge" $ do       let aOps = [WriteFileOp ".gitignore" "node_modules/\n.env\n" Template]           bOps = [PatchFileOp ".gitignore" ".env\n.claude/\n" AppendLineIfAbsent Template "mod-b"]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           content `shouldBe` "node_modules/\n.env\n.claude/\n"@@ -166,7 +193,7 @@     it "two Structured WriteFileOps for same JSON dest get deep-merged" $ do       let aOps = [WriteFileOp "config.json" "{\"name\": \"foo\"}\n" Structured]           bOps = [WriteFileOp "config.json" "{\"extra\": true}\n" Structured]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           T.isInfixOf "name" content `shouldBe` True@@ -179,7 +206,7 @@     it "disjoint JSON keys are combined" $ do       let aOps = [WriteFileOp "out.json" "{\"a\": 1}\n" Structured]           bOps = [WriteFileOp "out.json" "{\"b\": 2}\n" Structured]-          (ops, _, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           T.isInfixOf "\"a\"" content `shouldBe` True@@ -189,7 +216,7 @@     it "overlapping scalar keys use right-biased merge" $ do       let aOps = [WriteFileOp "out.json" "{\"x\": 1}\n" Structured]           bOps = [WriteFileOp "out.json" "{\"x\": 2}\n" Structured]-          (ops, _, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] ->           -- Right-biased: B's value wins@@ -199,7 +226,7 @@     it "nested objects are merged recursively" $ do       let aOps = [WriteFileOp "out.json" "{\"obj\": {\"a\": 1}}\n" Structured]           bOps = [WriteFileOp "out.json" "{\"obj\": {\"b\": 2}}\n" Structured]-          (ops, _, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           T.isInfixOf "\"a\"" content `shouldBe` True@@ -209,14 +236,14 @@     it "non-Structured WriteFileOps still use last-writer-wins" $ do       let aOps = [WriteFileOp "README.md" "from A" Template]           bOps = [WriteFileOp "README.md" "from B" Template]-          (_, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       any isFileOverwritten warnings `shouldBe` True       any isContentMerged warnings `shouldBe` False      it "YAML structured merge works" $ do       let aOps = [WriteFileOp "config.yaml" "name: foo\n" Structured]           bOps = [WriteFileOp "config.yaml" "extra: true\n" Structured]-          (ops, warnings, _) = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]+          Right ComposedPlan {operations = ops, warnings} = mergeOperations [("mod-a", aOps), ("mod-b", bOps)]       case filter isWriteOp ops of         [WriteFileOp _ content _] -> do           T.isInfixOf "name" content `shouldBe` True
test/Seihou/Core/ApplicationSpec.hs view
@@ -69,6 +69,7 @@       context = Nothing,       instances = [],       commandReceipts = Map.empty,+      checkoutCommands = [],       appliedAt = fixedTime     } 
test/Seihou/Core/ArtifactIdentitySpec.hs view
@@ -3,7 +3,7 @@ import Control.Monad (forM_) import Data.Text (Text) import Data.Text qualified as T-import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl)+import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl, normalizeOriginUrl) import Test.Hspec import Test.Tasty import Test.Tasty.Hspec (testSpec)@@ -39,7 +39,7 @@   ]  spec :: Spec-spec =+spec = do   describe "isMachineLocalOriginUrl" $ do     forM_ machineLocal $ \url ->       it ("treats " <> T.unpack url <> " as machine-local") $@@ -47,3 +47,16 @@     forM_ remote $ \url ->       it ("treats " <> T.unpack url <> " as reachable from other machines") $         isMachineLocalOriginUrl url `shouldBe` False++  describe "normalizeOriginUrl" $ do+    it "recognizes public GitHub SSH and HTTPS transports as the same repository" $+      map+        normalizeOriginUrl+        [ "git@github.com:shinzui/seihou-modules.git",+          "ssh://git@github.com/shinzui/seihou-modules.git/",+          "https://github.com/shinzui/seihou-modules/"+        ]+        `shouldBe` replicate 3 "https://github.com/shinzui/seihou-modules"+    it "keeps different repositories and hosts distinct" $ do+      normalizeOriginUrl "git@github.com:other/seihou-modules.git" `shouldNotBe` normalizeOriginUrl "https://github.com/shinzui/seihou-modules"+      normalizeOriginUrl "git@git.example:shinzui/seihou-modules.git" `shouldNotBe` normalizeOriginUrl "https://git.example/shinzui/seihou-modules"
test/Seihou/Core/CommandFingerprintSpec.hs view
@@ -16,7 +16,8 @@     { command = command,       workDir = workDir,       moduleName = moduleName,-      occurrence = occurrence+      occurrence = occurrence,+      lifecycle = ApplicationCommand     }  spec :: Spec
test/Seihou/Core/SeedSpec.hs view
@@ -1,5 +1,6 @@ module Seihou.Core.SeedSpec (tests) where +import Control.Monad (forM_) import Data.Either (isLeft) import Data.Generics.Labels () import Data.Map.Strict qualified as Map@@ -23,17 +24,72 @@ tests :: IO TestTree tests = testSpec "seed foundations" $ do   let now = UTCTime (fromGregorian 2026 10 15) 0-      first = SeedRecord "demo" (Set.singleton (ApplicationId "a")) SeedCreated now-      second = SeedRecord "demo" (Set.singleton (ApplicationId "b")) SeedFoundExisting now-      manifest = mergeSeedRecord "CHANGELOG.md" second (mergeSeedRecord "CHANGELOG.md" first (emptyManifest now))-  it "preserves the first settlement while merging owners" $ do+      a = ApplicationId "a"+      b = ApplicationId "b"+      claims app names = KnownSeedOwners (Map.singleton app (Set.fromList names))+      first = SeedRecord "demo" (LegacySeedOwners (Set.singleton a)) SeedCreated now+      second = SeedRecord "demo" (LegacySeedOwners (Set.singleton b)) SeedFoundExisting now+      Right manifest = mergeSeedRecord "CHANGELOG.md" first (emptyManifest now) >>= mergeSeedRecord "CHANGELOG.md" second+      application app =+        AppliedComposition+          app+          (AppliedModuleTarget "demo")+          (LocalOrigin "demo")+          Nothing+          []+          Nothing+          Nothing+          [AppliedInstanceState name emptyParentVars (LocalOrigin (name ^. #unModuleName)) Nothing Map.empty | name <- ["demo", "second"]]+          Map.empty+          []+          now+      exact = first & #ownership .~ claims a ["demo", "second"]+      known = emptyManifest now & #applications .~ map application [a, b] & #seeds .~ Map.singleton "CHANGELOG.md" exact+  it "preserves the first settlement while merging legacy owners" $ do     let Just receipt = Map.lookup "CHANGELOG.md" (manifest ^. #seeds)     receipt ^. #outcome `shouldBe` SeedCreated-    receipt ^. #applicationIds `shouldBe` Set.fromList [ApplicationId "a", ApplicationId "b"]-  it "releases only selected owners and drops the last claim" $ do-    let one = dropSeedOwner (Set.singleton (ApplicationId "a")) "CHANGELOG.md" manifest-    Map.size (one ^. #seeds) `shouldBe` 1-    Map.null (dropSeedOwner (Set.singleton (ApplicationId "b")) "CHANGELOG.md" one ^. #seeds) `shouldBe` True+    seedApplicationIds receipt `shouldBe` Set.fromList [a, b]+  it "removes the supplier without erasing a second seeder in the application" $ do+    Right one <- pure (dropSeedOwners (Map.singleton a (Set.singleton "demo")) "CHANGELOG.md" known)+    let Just receipt = Map.lookup "CHANGELOG.md" (one ^. #seeds)+    receipt ^. #ownership `shouldBe` claims a ["second"]+    receipt ^. #moduleName `shouldBe` "demo"+    Right lastGone <- pure (dropSeedOwners (Map.singleton a (Set.singleton "second")) "CHANGELOG.md" one)+    lastGone ^. #seeds `shouldBe` Map.empty+  it "unions exact claims idempotently and associatively across applications" $ do+    let receipt = second & #ownership .~ claims b ["demo"]+    Right merged <- pure (mergeSeedRecord "CHANGELOG.md" receipt known)+    mergeSeedRecord "CHANGELOG.md" exact merged `shouldBe` Right merged+    mergeSeedRecord "CHANGELOG.md" receipt merged `shouldBe` Right merged+    Right reverseMerged <- pure (mergeSeedRecord "CHANGELOG.md" exact (known & #seeds .~ Map.singleton "CHANGELOG.md" receipt))+    fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (merged ^. #seeds)) `shouldBe` fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (reverseMerged ^. #seeds))+    validateManifestInvariants merged `shouldBe` Right ()+  it "replaces selected claims without resetting settlement or losing unrelated owners" $ do+    Right shared <- pure (mergeSeedRecord "CHANGELOG.md" (second & #ownership .~ claims b ["demo"]) known)+    let candidate = second & #ownership .~ claims a ["second"] & #moduleName .~ "second"+    Right replaced <- pure (replaceSelectedSeedClaims (Set.singleton a) (Map.singleton "CHANGELOG.md" candidate) shared)+    let Just receipt = Map.lookup "CHANGELOG.md" (replaced ^. #seeds)+    receipt ^. #moduleName `shouldBe` "demo"+    receipt ^. #outcome `shouldBe` SeedCreated+    receipt ^. #ownership `shouldBe` KnownSeedOwners (Map.fromList [(a, Set.singleton "second"), (b, Set.singleton "demo")])+    Right retired <- pure (replaceSelectedSeedClaims (Set.singleton a) Map.empty replaced)+    fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (retired ^. #seeds)) `shouldBe` Just (claims b ["demo"])+    replaceSelectedSeedClaims (Set.singleton a) (Map.singleton "CHANGELOG.md" (second & #ownership .~ claims b ["demo"])) known `shouldSatisfy` isLeft+  it "never subtracts unresolved legacy claims or mixes exact and unknown facts" $ do+    dropSeedOwners (Map.singleton a (Set.singleton "demo")) "CHANGELOG.md" manifest `shouldSatisfy` isLeft+    mergeSeedRecord "CHANGELOG.md" exact manifest `shouldSatisfy` isLeft+    replaceSelectedSeedClaims (Set.singleton a) Map.empty manifest `shouldSatisfy` isLeft+    replaceSelectedSeedClaims (Set.singleton (ApplicationId "unrelated")) Map.empty manifest `shouldBe` Right manifest+  it "round trips known claims and reports downgrade loss" $ do+    manifestFromJSON (manifestToJSON known) `shouldBe` Right known+    manifestEncodingLoss (known & #version .~ ManifestSchemaVersion 10) `shouldContain` ["seed owner claims require manifest schema 11"]+  it "validates exact paths and owner references" $ do+    forM_ ["../escape", "/absolute", "./alias", "a//b", ".seihou/private"] $ \path ->+      validateManifestInvariants (known & #seeds .~ Map.singleton path exact) `shouldSatisfy` isLeft+    forM_ [KnownSeedOwners Map.empty, claims a [], claims (ApplicationId "missing") ["demo"], claims a ["missing"]] $ \owners ->+      validateManifestInvariants (known & #seeds .~ Map.singleton "a" (exact & #ownership .~ owners)) `shouldSatisfy` isLeft+    let file = FileRecord (SHA256 "hash") "demo" Copy now Nothing Set.empty SharedWriteUnknown+    validateManifestInvariants (known & #files .~ Map.singleton "CHANGELOG.md/child" file) `shouldSatisfy` isLeft   it "round trips receipts without hashes or baselines" $ do     manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest   it "rejects a managed/seed collision at decoding" $ do@@ -53,7 +109,7 @@           source field =             T.unlines               [ "let S = " <> T.pack schema,-                "in S.Module::{ name = \"demo\", version = Some \"1.0\", steps = [S.Step::{ strategy = \"template\", src = \"a\", dest = \"b\"" <> field <> " }] }"+                "in S.Module::{ name = \"demo\", version = Some \"1.0\" } // { steps = [S.Step::{ strategy = \"template\", src = \"a\", dest = \"b\" }" <> (if T.null field then "" else " // { " <> T.drop 2 field <> " }") <> "] }"               ]       TIO.writeFile (dir </> "module.dhall") (source "")       Right old <- evalModuleFromFile (dir </> "module.dhall")
test/Seihou/Core/StatusSpec.hs view
@@ -3,6 +3,7 @@ import Control.Lens ((&), (.~), (^.)) import Data.Generics.Labels () import Data.Map.Strict qualified as Map+import Data.Set qualified as Set import Data.Text (Text) import Data.Time (UTCTime, defaultTimeLocale, parseTimeOrError) import Effectful@@ -42,6 +43,13 @@  spec :: Spec spec = do+  it "never reads seed bytes or treats deliberate deletion as drift" $ do+    let receipt = SeedRecord modName (LegacySeedOwners Set.empty) SeedCreated fixedTime+        manifest = emptyManifest fixedTime & #seeds .~ Map.fromList [("unreadable.txt", receipt), ("deleted.txt", receipt)]+        (tracked, after) = runPureEff $ runFilesystemPure emptyFS $ computeTrackedFileStatuses manifest+    tracked `shouldBe` []+    after `shouldBe` emptyFS+   describe "computeTrackedFileStatuses" $ do     it "classifies a file matching its manifest hash as TfsUnchanged" $ do       let content = "# Hello World"
test/Seihou/Core/TypesSpec.hs view
@@ -134,7 +134,7 @@       src `shouldBe` "a.txt"      it "supports RunCommandOp" $ do-      let RunCommandOp {command} = RunCommandOp {command = "git init", workDir = Nothing, moduleName = "test", occurrence = 0}+      let RunCommandOp {command} = RunCommandOp {command = "git init", workDir = Nothing, moduleName = "test", occurrence = 0, lifecycle = ApplicationCommand}       command `shouldBe` "git init"    describe "Expr" $ do
test/Seihou/Dhall/EvalSpec.hs view
@@ -1,9 +1,11 @@ module Seihou.Dhall.EvalSpec (tests) where  import Control.Lens ((^.))+import Control.Monad (forM_) import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Text qualified as T+import Data.Text.IO qualified as TIO import Seihou.Core.Types import Seihou.Dhall.Eval (evalDhallExpr, evalModuleFromFile, evalRecipeFromFile) import System.Directory (createDirectoryIfMissing)@@ -57,6 +59,26 @@       result <- evalDhallExpr "{ name = \"my-project\", version = \"0.1.0\" }"       Map.lookup "name" result `shouldBe` Just "my-project"       Map.lookup "version" result `shouldBe` Just "0.1.0"++  describe "command lifecycle decoding" $ do+    let source command = T.replace "commands = [] : List { run : Text, workDir : Optional Text, when : Optional Text }" ("commands = [" <> command <> "]") (T.pack (moduleWithVar "value" "text" "None Text"))+        action variant = "{ action = < Apply : Text | Checkout : Text >." <> variant <> " \"echo setup\", workDir = None Text, when = None Text }"+    forM_ [("Apply", ApplicationCommand), ("Checkout", CheckoutCommandLifecycle)] $ \(variant, lifecycle) ->+      it ("decodes tagged " <> T.unpack variant <> " commands") $+        withSystemTempDirectory "command-decoder" $ \directory -> do+          let file = directory </> "module.dhall"+          TIO.writeFile file (source (action variant))+          result <- evalModuleFromFile file+          case result of+            Right modul -> map (^. #lifecycle) (modul ^. #commands) `shouldBe` [lifecycle]+            Left err -> expectationFailure (show err)+    forM_ ["{ run = \"echo legacy\", action = < Apply : Text | Checkout : Text >.Checkout \"echo setup\", workDir = None Text, when = None Text }", "{ action = < Unknown : Text >.Unknown \"echo setup\", workDir = None Text, when = None Text }"] $ \bad ->+      it "rejects ambiguous or unknown tagged commands inside the module exception boundary" $+        withSystemTempDirectory "command-decoder-invalid" $ \directory -> do+          let file = directory </> "module.dhall"+          TIO.writeFile file (source bad)+          result <- evalModuleFromFile file+          result `shouldSatisfy` either (const True) (const False)    describe "evalModuleFromFile" $ do     it "decodes the haskell-base fixture module" $ do
test/Seihou/Effect/ManifestStoreSpec.hs view
@@ -1,5 +1,6 @@ module Seihou.Effect.ManifestStoreSpec (tests) where +import Control.Exception (evaluate) import Control.Lens ((&), (.~), (^.)) import Data.Generics.Labels () import Data.Map.Strict qualified as Map@@ -14,7 +15,7 @@ import Seihou.Effect.ManifestStore (readManifest, writeManifest) import Seihou.Effect.ManifestStoreInterp (runManifestStore) import Seihou.Effect.ManifestStorePure (runManifestStorePure)-import Seihou.Manifest.Types (emptyManifest)+import Seihou.Manifest.Types (currentManifestVersion, emptyManifest) import System.Directory (doesFileExist) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory)@@ -61,6 +62,12 @@             writeManifest sampleManifest       finalState `shouldBe` Just sampleManifest +  describe "write validation" $ do+    it "refuses lossy exact ownership before writing any bytes" $ do+      let receipt = SeedRecord "haskell-base" (KnownSeedOwners (Map.singleton (ApplicationId "app") (Set.singleton "haskell-base"))) SeedCreated fixedTime+          invalid = sampleManifest & #version .~ ManifestSchemaVersion 10 & #seeds .~ Map.singleton "seed.txt" receipt+      evaluate (runPureEff (runManifestStorePure Nothing (writeManifest invalid))) `shouldThrow` anyIOException+   describe "real interpreter (via pure filesystem)" $ do     it "returns Right Nothing when manifest file does not exist" $ do       let manifestPath = ".seihou/manifest.json"@@ -88,7 +95,7 @@                 runManifestStore manifestPath (writeManifest sampleManifest)                 c <- readFileText manifestPath                 pure ((), c)-      T.isInfixOf "\"version\":9" content `shouldBe` True+      T.isInfixOf ("\"version\":" <> T.pack (show (currentManifestVersion ^. #unManifestSchemaVersion))) content `shouldBe` True       T.isInfixOf "haskell-base" content `shouldBe` True       T.isInfixOf "my-app" content `shouldBe` True 
test/Seihou/Engine/CheckoutSpec.hs view
@@ -37,7 +37,7 @@ fixtureManifest = emptyManifest now & #applications .~ [app] & #checkoutOutputs .~ Map.fromList [(dest, record source) | (dest, source) <- [(".local/config", "config/editor"), (".tool/assets", "assets/shared")]]   where     now = UTCTime (fromGregorian 2026 10 15) 0-    app = AppliedComposition (ApplicationId "a") (AppliedModuleTarget "demo") (LocalOrigin "demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (LocalOrigin "demo") (Just "1") Map.empty] Map.empty now+    app = AppliedComposition (ApplicationId "a") (AppliedModuleTarget "demo") (LocalOrigin "demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (LocalOrigin "demo") (Just "1") Map.empty] Map.empty [] now     record source = CheckoutOutputRecord (DirectorySymlink source) (Map.singleton (ApplicationId "a") (Set.singleton "demo"))  withFixture :: (FilePath -> IO a) -> IO a
test/Seihou/Engine/ExecuteSpec.hs view
@@ -90,7 +90,7 @@       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]+      let ops = [RunCommandOp "echo hello" Nothing modName 0 ApplicationCommand]           (records, _) = runExecFS emptyFS ops       Map.size records `shouldBe` 0 @@ -241,7 +241,7 @@       T.isInfixOf "copy" result `shouldBe` True      it "formats RunCommandOp" $ do-      let result = dryRunPlan [RunCommandOp "echo hello" Nothing modName 0]+      let result = dryRunPlan [RunCommandOp "echo hello" Nothing modName 0 ApplicationCommand]       T.isInfixOf "run" result `shouldBe` True      it "returns message for empty plan" $ do
test/Seihou/Engine/PlanSpec.hs view
@@ -6,6 +6,7 @@ import Data.Text qualified as T import Data.Text.Encoding qualified as T import Data.Yaml qualified as Yaml+import Seihou.Core.CommandFingerprint (fingerprintCommand) import Seihou.Core.Module (loadModule) import Seihou.Core.Types import Seihou.Engine.Plan@@ -728,6 +729,21 @@             T.isInfixOf "Structured" (errs !! 0) `shouldBe` True           Right _ -> expectationFailure "Expected Left" +    it "keeps checkout occurrences separate without invalidating application fingerprints" $ do+      let ordinary = Command "echo {{value}}" Nothing Nothing ApplicationCommand+          checkout = Command "echo {{value}}" Nothing Nothing CheckoutCommandLifecycle+          disabled = Command "never" Nothing (Just (ExprLit False)) CheckoutCommandLifecycle+          modul commands = Module "test" Nothing Nothing [] [] [] [] commands [] Nothing []+          values = Map.singleton "value" (VText "resolved")+      plain <- compilePlan "." (modul [ordinary, ordinary]) values+      mixed <- compilePlan "." (modul [checkout, ordinary, disabled, ordinary, checkout]) values+      case (plain, mixed) of+        (Right old, Right operations) -> do+          [(lifecycle, occurrence) | RunCommandOp {lifecycle, occurrence} <- operations] `shouldBe` [(CheckoutCommandLifecycle, 0), (ApplicationCommand, 0), (ApplicationCommand, 1), (CheckoutCommandLifecycle, 1)]+          map fingerprintCommand [operation | operation@RunCommandOp {lifecycle = ApplicationCommand} <- operations] `shouldBe` map fingerprintCommand old+          [command | RunCommandOp {command} <- operations] `shouldBe` replicate 4 "echo resolved"+        other -> expectationFailure (show other)+     it "compiles unconditional command to RunCommandOp" $ do       withFixture [("data.txt", "content")] $ \baseDir -> do         let modul =@@ -739,7 +755,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "echo hello" Nothing Nothing],+                  commands = [Command "echo hello" Nothing Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -765,9 +781,9 @@                   prompts = [],                   steps = [],                   commands =-                    [ Command "echo same" Nothing Nothing,-                      Command "echo other" Nothing Nothing,-                      Command "echo same" Nothing Nothing+                    [ Command "echo same" Nothing Nothing ApplicationCommand,+                      Command "echo other" Nothing Nothing ApplicationCommand,+                      Command "echo same" Nothing Nothing ApplicationCommand                     ],                   dependencies = [],                   removal = Nothing,@@ -792,7 +808,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "echo skip" Nothing (Just (ExprLit False))],+                  commands = [Command "echo skip" Nothing (Just (ExprLit False)) ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -816,7 +832,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "echo yes" Nothing (Just (ExprIsSet "name"))],+                  commands = [Command "echo yes" Nothing (Just (ExprIsSet "name")) ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -840,7 +856,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "echo post" Nothing Nothing],+                  commands = [Command "echo post" Nothing Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -873,7 +889,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "npm install" (Just "subdir") Nothing],+                  commands = [Command "npm install" (Just "subdir") Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -898,7 +914,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "echo {{name}}" Nothing Nothing],+                  commands = [Command "echo {{name}}" Nothing Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -923,7 +939,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "cabal build" (Just "{{name}}") Nothing],+                  commands = [Command "cabal build" (Just "{{name}}") Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -948,7 +964,7 @@                   exports = [],                   prompts = [],                   steps = [],-                  commands = [Command "cabal build" (Just "{{project.name}}") Nothing],+                  commands = [Command "cabal build" (Just "{{project.name}}") Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []@@ -973,7 +989,7 @@                   exports = [],                   prompts = [],                   steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],-                  commands = [Command "echo {{missing}}" Nothing Nothing],+                  commands = [Command "echo {{missing}}" Nothing Nothing ApplicationCommand],                   dependencies = [],                   removal = Nothing,                   migrations = []
test/Seihou/Engine/PreviewSpec.hs view
@@ -117,7 +117,7 @@         _ -> expectationFailure "Expected DirPreview"      it "produces CommandPreview for RunCommandOp" $ do-      let ops = [RunCommandOp "cabal build" Nothing modName 0]+      let ops = [RunCommandOp "cabal build" Nothing modName 0 ApplicationCommand]           result = buildPreview ops Nothing Map.empty       length result `shouldBe` 1       case head result of@@ -128,9 +128,9 @@      it "shows command ownership only when identical text has multiple owners" $ do       let ops =-            [ RunCommandOp "cabal build" Nothing modName 0,-              RunCommandOp "cabal build" Nothing modName2 0,-              RunCommandOp "cabal test" Nothing modName 0+            [ RunCommandOp "cabal build" Nothing modName 0 ApplicationCommand,+              RunCommandOp "cabal build" Nothing modName2 0 ApplicationCommand,+              RunCommandOp "cabal test" Nothing modName 0 ApplicationCommand             ]           result = buildPreview ops Nothing Map.empty       result
test/Seihou/Engine/ReconcileSpec.hs view
@@ -39,6 +39,21 @@             other -> expectationFailure ("expected one update, got " <> show other)         Left err -> expectationFailure (show err) +    it "adds newly declared ignore lines without conflicting with user appends or restoring deleted old lines" $ do+      let base = ".claude/\n.agents/\n"+          current = ".claude/\n.mina/\n# user rules\ndist-newstyle/\n"+          ref = baselineRefForContent base+          manifest = withFile ".gitignore" (record base (Just ref) [appA]) empty+          operations = [patch ".claude/\n.agents/\n.codex/" "owner"]+          result = planWith (Map.singleton ".gitignore" current) Map.empty (Map.singleton ref base) manifest [appA] operations (owners ".gitignore" [appA]) (\_ _ _ -> error "line additions must not invoke diff3")+      case result of+        Right reconciliation -> case (reconciliation ^. #files) Map.! ".gitignore" of+          FileAutoMerge _ state _ _ -> do+            state ^. #appliedContent `shouldBe` current <> ".codex/\n"+            state ^. #generatedBaseline `shouldBe` base <> ".codex/\n"+          other -> expectationFailure (show other)+        Left err -> expectationFailure (show err)+     it "replays a patch after a complete write" $ do       let operations =             [ WriteFileOp "README.md" "generated\n" Template,
test/Seihou/Engine/RemoveSpec.hs view
@@ -79,6 +79,29 @@  spec :: Spec spec = do+  describe "seed preservation and exact selection" $ do+    let appId = ApplicationId "a"+        app = AppliedComposition appId (AppliedModuleTarget modName) (LocalOrigin "test-module") Nothing [] Nothing Nothing [AppliedInstanceState name emptyParentVars (LocalOrigin text) Nothing Map.empty | (name, text) <- [(modName, "test-module"), (otherMod, "other-module")]] Map.empty [] fixedTime+        receipt = SeedRecord modName (KnownSeedOwners (Map.singleton appId (Set.fromList [modName, otherMod]))) SeedCreated fixedTime+        manifest = mkManifest False [] & #applications .~ [app] & #seeds .~ Map.singleton "seed.txt" receipt+    it "retires a no-spec instance without losing a co-seeder or original supplier" $ do+      Right (Just selection) <- pure (selectRecordedRemoval manifest modName Nothing)+      let candidate = selection ^. #candidateManifest+      fmap (^. #ownership) (Map.lookup "seed.txt" (candidate ^. #seeds)) `shouldBe` Just (KnownSeedOwners (Map.singleton appId (Set.singleton otherMod)))+      fmap (^. #moduleName) (Map.lookup "seed.txt" (candidate ^. #seeds)) `shouldBe` Just modName+    it "keeps every declarative removal form without needing to read seed content" $ do+      let removal = Removal [RemovalStep action "seed.txt" (Just "rewrite.dhall") | action <- [RemoveFileAction, RemoveSectionAction, RewriteFileAction]] []+      Right plan <- pure (runBuildOps emptyFS manifest modName removal)+      plan ^. #ops `shouldBe` replicate 3 (KeepSeedOp "seed.txt")+    it "defends direct supplied operations, including path aliases, at execution too" $ do+      let fs = mkFS [("seed.txt", "project bytes")]+          plan = ExecutedRemovalPlan modName [DeleteFileOp "seed.txt" RFSafe, StripSectionOp "./seed.txt", RewriteOp "seed.txt" "rewrite.dhall"]+          (_, after) = runExecOps fs manifest plan Set.empty+      after ^. #files `shouldBe` fs ^. #files+    it "refuses unresolved legacy subtraction rather than guessing from supplier" $ do+      let legacy = manifest & #seeds .~ Map.singleton "seed.txt" (receipt & #ownership .~ LegacySeedOwners (Set.singleton appId))+      selectRecordedRemoval legacy modName Nothing `shouldSatisfy` either (const True) (const False)+   describe "computeRemovalPlan" $ do     it "returns ModuleNotApplied when module is not in manifest" $ do       let manifest = emptyManifest fixedTime@@ -239,7 +262,7 @@         Right plan -> (plan ^. #ops) `shouldBe` [StripSectionOp ".gitignore"]      it "builds RemovalCommandOp for removal commands" $ do-      let removal = Removal [] [Command "cabal clean" Nothing Nothing]+      let removal = Removal [] [Command "cabal clean" Nothing Nothing ApplicationCommand]           manifest = mkManifestWithRemoval removal []           result = runBuildOps emptyFS manifest modName removal       case result of@@ -261,7 +284,7 @@       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]+      let removal = Removal [] [Command "echo unsafe" (Just "../outside") Nothing ApplicationCommand]           manifest = mkManifestWithRemoval removal []           result = runBuildOps emptyFS manifest modName removal       result@@ -276,7 +299,7 @@       let removal =             Removal               [RemovalStep RemoveFileAction "a.txt" Nothing, RemovalStep RemoveSectionAction ".gitignore" Nothing]-              [Command "echo done" Nothing Nothing]+              [Command "echo done" Nothing Nothing ApplicationCommand]           manifest = mkManifestWithRemoval removal [("a.txt", "aaa")]           fs = mkFS [("a.txt", "aaa"), (".gitignore", "stuff")]           result = runBuildOps fs manifest modName removal
+ test/Seihou/Engine/SeedSpec.hs view
@@ -0,0 +1,92 @@+module Seihou.Engine.SeedSpec (tests) where++import Control.Monad (forM_)+import Data.ByteString qualified as BS+import Data.Either (isLeft)+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text.IO qualified as TIO+import Data.Time (UTCTime (..), fromGregorian)+import Effectful (runPureEff)+import Seihou.Core.Types+import Seihou.Effect.Filesystem+import Seihou.Effect.FilesystemInterp (createFileExclusiveWith, runFilesystem)+import Seihou.Effect.FilesystemPure+import Seihou.Engine.Seed+import Seihou.Manifest.Types (emptyManifest)+import Seihou.Prelude+import System.Directory qualified as D+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "seed planning and exclusive creation" $ do+  let now = UTCTime (fromGregorian 2026 10 26) 0+      receipt = SeedRecord "supplier" (KnownSeedOwners (Map.singleton (ApplicationId "a") (Set.singleton "supplier"))) SeedCreated now+      managed = FileRecord (SHA256 "hash") "supplier" Copy now Nothing Set.empty SharedWriteUnknown+      operation = SeedFileOp "nested/start.txt" "starter\n" Copy "supplier"+      claims = Map.singleton "nested/start.txt" (Set.fromList ["supplier", "second"])+  it "uses one decision tree for every observed entry and lifecycle" $ do+    classifySeed Nothing Nothing EntryMissing `shouldBe` Right SeedWrite+    classifySeed Nothing Nothing EntryFile `shouldBe` Right SeedKeepExisting+    forM_ [EntryDirectory, EntrySymlink "missing"] $ \entry -> classifySeed Nothing Nothing entry `shouldSatisfy` isLeft+    forM_ [EntryMissing, EntryFile, EntryDirectory, EntrySymlink "missing"] $ \entry -> do+      classifySeed (Just receipt) Nothing entry `shouldBe` Right (if entry == EntryMissing then SeedRespectDeletion else SeedAlreadySettled)+      classifySeed Nothing (Just managed) entry `shouldBe` Right SeedReleaseManaged+  it "uses an explicit root, records every claimant, and never hashes a seed" $ do+    let ((planned, created), fs) = runPureEff $ runFilesystemPure emptyFS $ do+          result <- planSeeds "/project" (emptyManifest now) claims [operation]+          let Right planned = result+          created <- executeSeeds "/project" (ApplicationId "a") now planned (emptyManifest now)+          pure (planned, created)+    map (^. #supplier) planned `shouldBe` ["supplier"]+    fs ^. #files `shouldBe` Map.singleton "/project/nested/start.txt" "starter\n"+    fmap (fmap (^. #ownership)) created `shouldBe` Right (Map.singleton "nested/start.txt" (KnownSeedOwners (Map.singleton (ApplicationId "a") (Set.fromList ["supplier", "second"]))))+  it "does not read, repair, or create parents for a settled deleted seed" $ do+    let manifest = emptyManifest now & #seeds .~ Map.singleton "nested/start.txt" receipt+        ((planned, result), fs) = runPureEff $ runFilesystemPure emptyFS $ do+          result <- planSeeds "/project" manifest claims [operation]+          let Right planned = result+          executed <- executeSeeds "/project" (ApplicationId "a") now planned manifest+          pure (planned, executed)+    map (^. #decision) planned `shouldBe` [SeedRespectDeletion]+    result `shouldSatisfy` either (const False) (const True)+    fs `shouldBe` emptyFS+  it "refuses lifecycle transitions even when managed content is missing" $ do+    validateSeedRun (emptyManifest now & #files .~ Map.singleton "nested/start.txt" managed) [operation] `shouldSatisfy` isLeft+    validateSeedRun (emptyManifest now & #seeds .~ Map.singleton "nested/start.txt" receipt) [WriteFileOp "nested/start.txt" "replacement" Copy] `shouldSatisfy` isLeft+  it "publishes complete bytes once and cannot replace files, directories or dangling links" $+    withSystemTempDirectory "seed-exclusive" $ \root -> do+      runEff (runFilesystem (createFileExclusive (root </> "file") "complete\n")) `shouldReturn` ExclusiveCreated+      runEff (runFilesystem (createFileExclusive (root </> "file") "replacement\n")) `shouldReturn` ExclusiveOccupied+      TIO.readFile (root </> "file") `shouldReturn` "complete\n"+      D.createDirectory (root </> "dir")+      D.createFileLink "missing" (root </> "link")+      forM_ ["dir", "link"] $ \path -> runEff (runFilesystem (createFileExclusive (root </> path) "replacement")) `shouldReturn` ExclusiveOccupied+      D.getSymbolicLinkTarget (root </> "link") `shouldReturn` "missing"+  it "cleans a failed partial private write without exposing a destination" $+    withSystemTempDirectory "seed-partial" $ \root -> do+      let failWriting handle = BS.hPut handle "partial" >> ioError (userError "injected write failure")+      result <- createFileExclusiveWith failWriting (root </> "seed")+      result `shouldSatisfy` (\case ExclusiveFailure _ -> True; _ -> False)+      D.listDirectory root `shouldReturn` []+  it "keeps a file appearing after preview and refuses a newly appearing link" $+    withSystemTempDirectory "seed-race" $ \root -> do+      Right planned <- runEff $ runFilesystem $ planSeeds root (emptyManifest now) claims [operation]+      D.createDirectory (root </> "nested")+      TIO.writeFile (root </> "nested/start.txt") "concurrent owner\n"+      Right actual <- runEff $ runFilesystem $ executeSeeds root (ApplicationId "a") now planned (emptyManifest now)+      fmap (^. #outcome) (Map.lookup "nested/start.txt" actual) `shouldBe` Just SeedFoundExisting+      TIO.readFile (root </> "nested/start.txt") `shouldReturn` "concurrent owner\n"+      D.removeFile (root </> "nested/start.txt")+      D.createFileLink "missing" (root </> "nested/start.txt")+      runEff (runFilesystem (executeSeeds root (ApplicationId "a") now planned (emptyManifest now))) >>= (`shouldSatisfy` isLeft)+  it "does not traverse a symlink parent" $+    withSystemTempDirectory "seed-parent" $ \root -> do+      D.createDirectory (root </> "elsewhere")+      D.createDirectoryLink "elsewhere" (root </> "nested")+      runEff (runFilesystem (planSeeds root (emptyManifest now) claims [operation])) >>= (`shouldSatisfy` isLeft)+      D.listDirectory (root </> "elsewhere") `shouldReturn` []
+ test/Seihou/Engine/SeedTransactionSpec.hs view
@@ -0,0 +1,152 @@+module Seihou.Engine.SeedTransactionSpec (tests) where++import Control.Exception (IOException)+import Data.Either (isLeft)+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text.IO qualified as TIO+import Data.Time (UTCTime (..), fromGregorian)+import Seihou.Core.Migration qualified as Migration+import Seihou.Core.Types hiding (KeepCurrent)+import Seihou.Effect.BaselineStorePure (runBaselineStorePure)+import Seihou.Effect.Filesystem (PathEntry (..))+import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Engine.Migrate (validateSeedMigration)+import Seihou.Engine.Reconcile+import Seihou.Engine.Seed (SeedDecision (..))+import Seihou.Engine.UpdateTransaction+import Seihou.Manifest.Hash (baselineRefForContent, hashContent)+import Seihou.Manifest.Types (emptyManifest)+import Seihou.Prelude+import System.Directory qualified as D+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "seed update transactions" $ do+  let now = UTCTime (fromGregorian 2026 10 26) 0+      appId = ApplicationId "app"+      app = AppliedComposition appId (AppliedModuleTarget "seeder") (LocalOrigin "seeder") Nothing [] Nothing Nothing [AppliedInstanceState "seeder" emptyParentVars (LocalOrigin "seeder") Nothing Map.empty] Map.empty [] now+      manifest = emptyManifest now & #applications .~ [app]+      claims = Map.singleton appId (Set.singleton "seeder")+      createAction = SeedAction "seed.txt" "seeder" claims SeedWrite (Just "seed bytes\n")+      createPlan = ReconciliationPlan (Set.singleton appId) (Map.singleton "seed.txt" (FileSeedCreate createAction EntryMissing)) Set.empty+      begin root = beginSeedUpdateTransaction root (reconciliationMutationPaths createPlan) (Set.singleton "seed.txt")+      receipt = SeedRecord "seeder" (KnownSeedOwners claims) SeedCreated now+      priorSeed = manifest & #seeds .~ Map.singleton "seed.txt" receipt+      managedOwners = Map.singleton "seed.txt" (DesiredFileOwner "seeder" (Set.singleton appId))+      plan root prior ops owners seedClaims =+        fmap fst $+          runEff $+            runFilesystem $+              runBaselineStorePure Map.empty $+                planSeedReconciliation root root prior (Set.singleton appId) ops owners seedClaims+  it "requires explicit adoption of equal seed bytes and records keep-current against the generated baseline" $+    withSystemTempDirectory "seed-adoption" $ \root -> do+      TIO.writeFile (root </> "seed.txt") "project bytes\n"+      Right pending <- plan root priorSeed [WriteFileOp "seed.txt" "candidate bytes\n" Copy] managedOwners Map.empty+      unresolvedPaths pending `shouldBe` Set.singleton "seed.txt"+      Right kept <- pure (resolveFileConflict "seed.txt" KeepCurrent pending)+      Right transaction <- beginSeedUpdateTransaction root (reconciliationMutationPaths kept) Set.empty+      Right final <- applyReconciliation transaction kept priorSeed+      final ^. #seeds `shouldBe` Map.empty+      fmap (^. #hash) (Map.lookup "seed.txt" (final ^. #files)) `shouldBe` Just (hashContent "project bytes\n")+      fmap (^. #baseline) (Map.lookup "seed.txt" (final ^. #files)) `shouldBe` Just (Just (baselineRefForContent "candidate bytes\n"))+      TIO.readFile (root </> "seed.txt") `shouldReturn` "project bytes\n"+      completeUpdateTransaction transaction `shouldReturn` Right ()+  it "offers only explicit recreation for a missing seed and accepts generated bytes" $+    withSystemTempDirectory "seed-adopt-missing" $ \root -> do+      Right pending <- plan root priorSeed [WriteFileOp "seed.txt" "candidate bytes\n" Copy] managedOwners Map.empty+      resolveFileConflict "seed.txt" KeepCurrent pending `shouldSatisfy` isLeft+      resolveFileConflict "seed.txt" WriteConflictMarkers pending `shouldSatisfy` isLeft+      Right accepted <- pure (resolveFileConflict "seed.txt" AcceptGenerated pending)+      Right transaction <- beginSeedUpdateTransaction root (reconciliationMutationPaths accepted) Set.empty+      Right final <- applyReconciliation transaction accepted priorSeed+      final ^. #seeds `shouldBe` Map.empty+      TIO.readFile (root </> "seed.txt") `shouldReturn` "candidate bytes\n"+      completeUpdateTransaction transaction `shouldReturn` Right ()+  it "preserves unselected seed claims when the selected application retires its claim" $+    withSystemTempDirectory "seed-partial-retirement" $ \root -> do+      let other = app & #applicationId .~ ApplicationId "other"+          sharedClaims = Map.insert (ApplicationId "other") (Set.singleton "seeder") claims+          prior = priorSeed & #applications %~ (<> [other]) & #seeds .~ Map.singleton "seed.txt" (receipt & #ownership .~ KnownSeedOwners sharedClaims)+      D.createDirectory (root </> "seed.txt")+      Right pending <- plan root prior [] Map.empty Map.empty+      reconciliationMutationPaths pending `shouldBe` Set.empty+      Right transaction <- beginSeedUpdateTransaction root Set.empty Set.empty+      Right final <- applyReconciliation transaction pending prior+      fmap (^. #ownership) (Map.lookup "seed.txt" (final ^. #seeds)) `shouldBe` Just (KnownSeedOwners (Map.delete appId sharedClaims))+      D.doesDirectoryExist (root </> "seed.txt") `shouldReturn` True+      completeUpdateTransaction transaction `shouldReturn` Right ()+  it "does not grant release over an unselected managed owner, even with additive certification" $+    withSystemTempDirectory "seed-release-owner" $ \root -> do+      let record = FileRecord (hashContent "generated") "seeder" Copy now Nothing (Set.fromList [appId, ApplicationId "other"]) SharedWriteAdditiveOnly+      result <- plan root (manifest & #files .~ Map.singleton "seed.txt" record) [SeedFileOp "seed.txt" "seed" Copy "seeder"] Map.empty (Map.singleton "seed.txt" claims)+      result `shouldBe` Left (SeedReleaseBlockedByOwners "seed.txt" (Set.singleton (ApplicationId "other")))+  it "guards every declarative migration source, destination, ancestor and portable alias" $ do+    let protected = Set.singleton "nested/seed.txt"+        ops = [Migration.MoveFile "nested/seed.txt" "elsewhere", Migration.MoveFile "elsewhere" "nested/seed.txt", Migration.MoveDir "nested" "other", Migration.MoveDir "other" "nested", Migration.DeleteFile "nested/seed.txt", Migration.DeleteDir "nested", Migration.DeleteFile "NESTED/seed.txt"]+    mapM_ (\op -> validateSeedMigration protected [op] `shouldSatisfy` isLeft) ops+    validateSeedMigration protected [Migration.DeleteFile "unrelated"] `shouldBe` Right ()+  it "releases edited managed files without reading or restoring project bytes" $+    withSystemTempDirectory "seed-release-transaction" $ \root -> do+      let record = FileRecord (hashContent "generated") "seeder" Copy now Nothing (Set.singleton appId) SharedWriteRequiresOwnershipClosure+          prior = manifest & #files .~ Map.singleton "seed.txt" record+          action = SeedAction "seed.txt" "seeder" claims SeedReleaseManaged Nothing+          plan = ReconciliationPlan (Set.singleton appId) (Map.singleton "seed.txt" (FileSeedRelease action record)) Set.empty+      -- A directory cannot be read as text or journaled as an ordinary file;+      -- metadata release still preserves this project-owned replacement.+      D.createDirectory (root </> "seed.txt")+      Right transaction <- beginSeedUpdateTransaction root (reconciliationMutationPaths plan) Set.empty+      Right candidate <- applyReconciliation transaction plan prior+      candidate ^. #files `shouldBe` Map.empty+      fmap (^. #outcome) (Map.lookup "seed.txt" (candidate ^. #seeds)) `shouldBe` Just SeedReleasedFromManaged+      rollbackUpdateTransaction transaction `shouldReturn` Right ()+      D.doesDirectoryExist (root </> "seed.txt") `shouldReturn` True+  it "does not resurrect a missing managed path on release" $+    withSystemTempDirectory "seed-release-missing" $ \root -> do+      let record = FileRecord (hashContent "generated") "seeder" Copy now Nothing (Set.singleton appId) SharedWriteRequiresOwnershipClosure+          action = SeedAction "seed.txt" "seeder" claims SeedReleaseManaged Nothing+          plan = ReconciliationPlan (Set.singleton appId) (Map.singleton "seed.txt" (FileSeedRelease action record)) Set.empty+      Right transaction <- beginSeedUpdateTransaction root Set.empty Set.empty+      Right _ <- applyReconciliation transaction plan (manifest & #files .~ Map.singleton "seed.txt" record)+      D.doesPathExist (root </> "seed.txt") `shouldReturn` False+      rollbackUpdateTransaction transaction `shouldReturn` Right ()+  it "aborts a creation that became occupied without restoring over the new object" $+    withSystemTempDirectory "seed-stale" $ \root -> do+      Right transaction <- begin root+      TIO.writeFile (root </> "seed.txt") "concurrent project file\n"+      result <- applyReconciliation transaction createPlan manifest+      result `shouldSatisfy` isLeft+      TIO.readFile (root </> "seed.txt") `shouldReturn` "concurrent project file\n"+      recoverIncompleteTransactions root >>= (`shouldSatisfy` all (== Right ()))+  it "rolls back only an unchanged seed actually created by the transaction" $+    withSystemTempDirectory "seed-rollback" $ \root -> do+      Right transaction <- begin root+      result <- applyReconciliationWithHook (\_ -> ioError (userError "injected failure")) transaction createPlan manifest+      result `shouldSatisfy` isLeft+      D.doesPathExist (root </> "seed.txt") `shouldReturn` False+  it "recovers completed creation evidence after restart" $+    withSystemTempDirectory "seed-restart" $ \root -> do+      Right transaction <- begin root+      Right _ <- applyReconciliation transaction createPlan manifest+      TIO.readFile (root </> "seed.txt") `shouldReturn` "seed bytes\n"+      recoverIncompleteTransactions root >>= (`shouldSatisfy` all (== Right ()))+      D.doesPathExist (root </> "seed.txt") `shouldReturn` False+  it "preserves later edits and identical replacement objects during failed rollback" $+    mapM_+      ( \replace -> withSystemTempDirectory "seed-rollback-project-edit" $ \root -> do+          Right transaction <- begin root+          let change _ = do+                if replace then D.removeFile (root </> "seed.txt") else pure ()+                TIO.writeFile (root </> "seed.txt") (if replace then "seed bytes\n" else "project edit\n")+                ioError (userError "injected failure")+          result <- applyReconciliationWithHook change transaction createPlan manifest+          result `shouldSatisfy` isLeft+          D.doesFileExist (root </> "seed.txt") `shouldReturn` True+          TIO.readFile (root </> "seed.txt") `shouldReturn` (if replace then "seed bytes\n" else "project edit\n")+      )+      [False, True]
test/Seihou/Engine/ValidateSpec.hs view
@@ -1,6 +1,6 @@ module Seihou.Engine.ValidateSpec (tests) where -import Control.Lens ((^.))+import Control.Lens ((&), (.~), (^.)) import Data.Generics.Labels () import Data.Text qualified as T import Seihou.Core.Types@@ -461,11 +461,17 @@       reportHasErrors report `shouldBe` True    describe "command safety" $ do+    it "rejects repeatable setup in removal commands" $ do+      let modul = goodModule & #removal .~ Just (Removal [] [Command "echo setup" Nothing Nothing CheckoutCommandLifecycle])+      withSystemTempDirectory "seihou-removal-checkout" $ \baseDir -> do+        report <- buildReport False baseDir modul+        hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True+     it "passes for valid commands" $ do       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do         createDirectoryIfMissing True (tmpDir </> "files")         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"-        let m = withCommands [Command "echo hello" Nothing Nothing] goodModule+        let m = withCommands [Command "echo hello" Nothing Nothing ApplicationCommand] goodModule         report <- buildReport False tmpDir m         hasPassedCheck "Command safety" (report ^. #checks) `shouldBe` True @@ -473,7 +479,7 @@       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do         createDirectoryIfMissing True (tmpDir </> "files")         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"-        let m = withCommands [Command "  " Nothing Nothing] goodModule+        let m = withCommands [Command "  " Nothing Nothing ApplicationCommand] goodModule         report <- buildReport False tmpDir m         hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True @@ -481,7 +487,7 @@       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do         createDirectoryIfMissing True (tmpDir </> "files")         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"-        let m = withCommands [Command "echo hi" (Just "/usr/local") Nothing] goodModule+        let m = withCommands [Command "echo hi" (Just "/usr/local") Nothing ApplicationCommand] goodModule         report <- buildReport False tmpDir m         hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True @@ -489,6 +495,6 @@       withSystemTempDirectory "seihou-validate" $ \tmpDir -> do         createDirectoryIfMissing True (tmpDir </> "files")         writeFile (tmpDir </> "files" </> "README.md.tpl") "stub"-        let m = withCommands [Command "echo hi" (Just "../escape") Nothing] goodModule+        let m = withCommands [Command "echo hi" (Just "../escape") Nothing ApplicationCommand] goodModule         report <- buildReport False tmpDir m         hasFailedCheck "Command safety" (report ^. #checks) `shouldBe` True
test/Seihou/Integration/CompositionSpec.hs view
@@ -8,7 +8,7 @@ import Data.Text qualified as T import Seihou.Composition.Graph (buildGraph, topoSort) import Seihou.Composition.Instance (ModuleInstance (..), primaryInstance)-import Seihou.Composition.Plan (compileComposedPlan)+import Seihou.Composition.Plan (ComposedPlan (..), compileComposedPlan) import Seihou.Composition.Resolve (loadComposition, resolveComposedVariables) import Seihou.Core.Types import System.FilePath ((</>))@@ -129,7 +129,7 @@               planResult <- compileComposedPlan quads               case planResult of                 Left errs -> expectationFailure $ "Plan failed: " ++ show errs-                Right (ops, warnings, _) -> do+                Right ComposedPlan {operations = ops, warnings} -> do                   -- Should have operations for shell.nix, flake.nix, README.md,                   -- src/Lib.hs, LICENSE, *.cabal, cabal.project, Makefile (+ dirs)                   let writeOps = [d | WriteFileOp d _ _ <- ops]@@ -155,7 +155,7 @@               planResult <- compileComposedPlan quads               case planResult of                 Left errs -> expectationFailure $ "Plan failed: " ++ show errs-                Right (ops, warnings, _) -> do+                Right ComposedPlan {operations = ops, warnings} -> do                   -- Find the merged README.md                   let readmeOps = [content | WriteFileOp dest content _ <- ops, dest == "README.md"]                   length readmeOps `shouldBe` 1@@ -184,7 +184,7 @@               planResult <- compileComposedPlan quads               case planResult of                 Left errs -> expectationFailure $ "Plan failed: " ++ show errs-                Right (ops, warnings, _) -> do+                Right ComposedPlan {operations = ops, warnings} -> do                   -- Find the merged config.json                   let configOps = [content | WriteFileOp dest content _ <- ops, dest == "config.json"]                   length configOps `shouldBe` 1@@ -245,7 +245,7 @@               planResult <- compileComposedPlan quads               case planResult of                 Left errs -> expectationFailure $ "Plan failed: " ++ show errs-                Right (ops, _, _) -> do+                Right ComposedPlan {operations = ops} -> do                   let writeDests = [d | WriteFileOp d _ _ <- ops]                   elem "out/leaf.txt" writeDests `shouldBe` True                   elem "out/diamond.txt" writeDests `shouldBe` True
test/Seihou/Manifest/TypesSpec.hs view
@@ -107,6 +107,7 @@                      }                  ],                commandReceipts = Map.empty,+               checkoutCommands = [],                appliedAt = fixedTime              }          ]@@ -123,7 +124,7 @@ manifestWithEveryStringPosition =   manifestWithEveryOriginPosition     & #checkoutOutputs .~ Map.singleton ".local/shared" (CheckoutOutputRecord (DirectorySymlink "assets/shared") (Map.singleton (ApplicationId "app") (Set.singleton "haskell-base")))-    & #seeds .~ Map.singleton "CHANGELOG.md" (SeedRecord "haskell-base" (Set.singleton (ApplicationId "app")) SeedCreated fixedTime)+    & #seeds .~ Map.singleton "CHANGELOG.md" (SeedRecord "haskell-base" (KnownSeedOwners (Map.singleton (ApplicationId "app") (Set.singleton "docs"))) SeedCreated fixedTime)     & #modules       .~ [ AppliedModule              { name = ModuleName "haskell-base",@@ -135,7 +136,7 @@                  Just                    ( Removal                        [RemovalStep RemoveFileAction "flake.nix" (Just "files/flake.nix")]-                       [Command "cabal clean" (Just "backend") Nothing]+                       [Command "cabal clean" (Just "backend") Nothing ApplicationCommand]                    )              }          ]@@ -193,6 +194,7 @@       context = composition ^. #context,       instances = composition ^. #instances,       commandReceipts = receipts,+      checkoutCommands = [],       appliedAt = composition ^. #appliedAt     } @@ -263,6 +265,35 @@       encoded `shouldSatisfy` not . isInfixOf "\"source\""       encoded `shouldSatisfy` not . isInfixOf "\"targetSource\"" +  describe "checkout command codec" $ do+    let command = CheckoutCommand "demo" 0 "mkdir -p .local/setup" Nothing+        application commands = AppliedComposition (ApplicationId "setup") (AppliedModuleTarget "demo") (ProjectOrigin "modules/demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (ProjectOrigin "modules/demo") (Just "1") Map.empty] Map.empty commands fixedTime+        manifest commands = emptyManifest fixedTime & #applications .~ [application commands]+        mutateApp transform value = case value of+          Aeson.Object fields -> case KeyMap.lookup "applications" fields of+            Just (Aeson.Array apps) -> Aeson.Object (KeyMap.insert "applications" (Aeson.Array (fmap (\case Aeson.Object obj -> Aeson.Object (transform obj); other -> other) apps)) fields)+            _ -> value+          other -> other+        decoded transform = manifestFromJSON (Aeson.encode (mutateApp transform (Aeson.toJSON (manifest [command]))))+        rejected result = result `shouldSatisfy` either (const True) (const False)+    it "roundtrips resolved setup with no completion receipts" $ do+      manifestFromJSON (manifestToJSON (manifest [command])) `shouldBe` Right (manifest [command])+    it "requires a list at schema 10, even when empty" $ do+      rejected (decoded (KeyMap.delete "checkoutCommands"))+      rejected (decoded (KeyMap.insert "checkoutCommands" Aeson.Null))+    it "refuses unknown kinds, missing fields, owners, duplicate identities and duplicate applications" $ do+      let wire = KeyMap.fromList [("kind", Aeson.String "shell"), ("module", Aeson.String "demo"), ("occurrence", Aeson.toJSON (0 :: Int)), ("command", Aeson.String "echo setup"), ("workDir", Aeson.Null)]+      forM_ [KeyMap.insert "kind" (Aeson.String "unknown") wire, KeyMap.delete "workDir" wire, KeyMap.insert "module" (Aeson.String "missing") wire, KeyMap.insert "occurrence" (Aeson.toJSON (-1 :: Int)) wire] $ \bad -> rejected (decoded (KeyMap.insert "checkoutCommands" (Aeson.toJSON [Aeson.Object bad])))+      rejected (manifestFromJSON (manifestToJSON (manifest [command, command])))+      rejected (manifestFromJSON (manifestToJSON (manifest [] & #applications .~ [application [], application []])))+    it "keeps legacy encodings empty and reports downgrade loss" $ do+      let old = manifest [] & #version .~ ManifestSchemaVersion 9+      manifestFromJSON (manifestToJSON old) `shouldBe` Right old+      LBS8.unpack (manifestToJSON old) `shouldNotSatisfy` isInfixOf "checkoutCommands"+      manifestEncodingLoss (manifest [command] & #version .~ ManifestSchemaVersion 9) `shouldSatisfy` (not . null)+      let smuggled = mutateApp (KeyMap.insert "checkoutCommands" (Aeson.toJSON [Aeson.object ["kind" Aeson..= ("shell" :: T.Text)]])) (Aeson.toJSON old)+      rejected (manifestFromJSON (Aeson.encode smuggled))+   describe "emptyManifest" $ do     it "creates a manifest with the current version" $ do       let m = emptyManifest fixedTime@@ -486,6 +517,7 @@                     AppliedInstanceState (ModuleName "link-skill") pv2 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "master-plan")                   ],                 commandReceipts = Map.singleton fingerprint receipt,+                checkoutCommands = [],                 appliedAt = fixedTime               }           application2 =@@ -499,6 +531,7 @@                 context = Nothing,                 instances = [],                 commandReceipts = Map.empty,+                checkoutCommands = [],                 appliedAt = fixedTime2               }           fileRecord =@@ -668,6 +701,7 @@                 context = Nothing,                 instances = [],                 commandReceipts = Map.empty,+                checkoutCommands = [],                 appliedAt = fixedTime               }           fileRecord = FileRecord (SHA256 "hash") "base" Template fixedTime Nothing mempty SharedWriteUnknown
test/Seihou/Manifest/UpgradeSpec.hs view
@@ -7,6 +7,7 @@ import Data.Aeson.KeyMap qualified as KeyMap import Data.Generics.Labels () import Data.Map.Strict qualified as Map+import Data.Set qualified as Set import Data.Text qualified as T import Seihou.Core.Types import Seihou.Manifest.Types@@ -97,6 +98,61 @@       let kindFrom source = upgradeStepKind . (^. #action) <$> lookup (v source) [(s ^. #fromVersion, s) | s <- manifestUpgradeSteps]       kindFrom 5 `shouldBe` Just InferenceBearingUpgrade       kindFrom 6 `shouldBe` Just LosslessUpgrade++  describe "the 10 -> 11 seed ownership step" $ do+    let legacy apps extra =+          Aeson.object+            ( [ "module" Aeson..= ("first" :: String),+                "applications" Aeson..= apps,+                "outcome" Aeson..= ("created" :: String),+                "settledAt" Aeson..= ("2026-03-01T10:30:00Z" :: String)+              ]+                <> extra+            )+        document receipt = case upgradeDocumentLosslessly (v 10) schema6Document of+          Right (_, Aeson.Object top) -> Aeson.Object (KeyMap.insert "seeds" (Aeson.object ["CHANGELOG.md" Aeson..= receipt]) top)+          _ -> error "invalid fixture"+    it "preserves application-only uncertainty and unknown fields losslessly" $ do+      let old = document (legacy (["a", "b"] :: [String]) ["futureKey" Aeson..= True])+      Right (steps, upgraded) <- pure (upgradeDocumentLosslessly (v 11) old)+      map (^. #action) steps `shouldBe` [MakeSeedOwnershipExplicit]+      map (upgradeStepKind . (^. #action)) steps `shouldBe` [LosslessUpgrade]+      case upgraded of+        Aeson.Object top -> do+          KeyMap.lookup "producerOwned" top `shouldBe` Just (Aeson.object ["keep" Aeson..= True])+          KeyMap.lookup "checkoutOutputs" top `shouldBe` Just (Aeson.object [])+          case KeyMap.lookup "seeds" top of+            Just (Aeson.Object seeds) -> case KeyMap.lookup "CHANGELOG.md" seeds of+              Just (Aeson.Object receipt) -> do+                KeyMap.lookup "applications" receipt `shouldBe` Nothing+                KeyMap.lookup "futureKey" receipt `shouldBe` Just (Aeson.Bool True)+                KeyMap.lookup "ownership" receipt `shouldBe` Just (Aeson.object ["kind" Aeson..= ("legacy-applications" :: String), "applications" Aeson..= (["a", "b"] :: [String])])+              _ -> expectationFailure "missing receipt"+            _ -> expectationFailure "missing seeds"+        _ -> expectationFailure "not an object"+      Right decoded <- pure (manifestFromJSON (Aeson.encode upgraded))+      fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (decoded ^. #seeds)) `shouldBe` Just (LegacySeedOwners (Set.fromList [ApplicationId "a", ApplicationId "b"]))+      manifestFromJSON (manifestToJSON decoded) `shouldBe` Right decoded+    it "preserves the old representation on every supported seed schema" $+      forM_ [8, 9, 10] $ \version -> do+        let old = setDocumentSchemaVersion (v version) (document (legacy (["a"] :: [String]) []))+        Right decoded <- pure (manifestFromJSON (Aeson.encode old))+        manifestFromJSON (manifestToJSON decoded) `shouldBe` Right decoded+        fmap (^. #ownership) (Map.lookup "CHANGELOG.md" (decoded ^. #seeds)) `shouldBe` Just (LegacySeedOwners (Set.singleton (ApplicationId "a")))+    it "rejects malformed legacy owners without stamping" $+      forM_ [Aeson.Bool True, Aeson.String "a", Aeson.toJSON [False]] $ \apps ->+        upgradeDocumentLosslessly (v 11) (document (legacy apps [])) `shouldSatisfy` either (const True) (const False)+    it "rejects unknown ownership kinds and duplicate authoritative fields" $ do+      let malformed = [Aeson.object ["kind" Aeson..= ("future" :: String)], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= True]]+      forM_ malformed $ \ownership -> do+        let raw = setDocumentSchemaVersion (v 11) (document (legacy (["a"] :: [String]) ["ownership" Aeson..= ownership]))+        manifestFromJSON (Aeson.encode raw) `shouldSatisfy` either (const True) (const False)++    it "requires valid tagged ownership without relying on duplicate field rejection" $ do+      let receipt ownership = Aeson.object ["module" Aeson..= ("first" :: String), "outcome" Aeson..= ("created" :: String), "settledAt" Aeson..= ("2026-03-01T10:30:00Z" :: String), "ownership" Aeson..= ownership]+          malformed = [Aeson.object ["kind" Aeson..= ("future" :: String)], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= True], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= Aeson.object []], Aeson.object ["kind" Aeson..= ("instances" :: String), "owners" Aeson..= Aeson.object ["a" Aeson..= (["x", "x"] :: [String])]]]+      forM_ malformed $ \ownership ->+        manifestFromJSON (Aeson.encode (setDocumentSchemaVersion (v 11) (document (receipt ownership)))) `shouldSatisfy` either (const True) (const False)    describe "the 7 -> 8 step" $ do     it "preserves unrelated raw fields and adds seed receipts" $ do
+ test/native/SeedExclusive.hs view
@@ -0,0 +1,62 @@+{-# LANGUAGE ImportQualifiedPost #-}++-- Run with: runghc -XGHC2024 -iseihou-core/src seihou-core/test/native/SeedExclusive.hs+-- Uses only GHC boot libraries, so the exact native implementation can also be+-- tested in a clean Linux compiler container without the application dependency graph.+import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar)+import Control.Exception (bracket)+import Control.Monad (forM, forM_, unless)+import Data.ByteString.Char8 qualified as BS+import Seihou.Filesystem.Exclusive+import System.Directory qualified as D+import System.FilePath ((</>))+import System.IO (hClose, openBinaryTempFile)+import System.Posix.Files qualified as Posix++check condition message = unless condition (ioError (userError message))++main :: IO ()+main = bracket setup D.removeDirectoryRecursive $ \root -> do+  let path = root </> "seed"+      publish text = createFileExclusiveWith (\handle -> BS.hPutStr handle (BS.pack text)) path+  publish "complete bytes\n" >>= \result -> check (result == ExclusiveCreated) "initial creation failed"+  publish "replacement" >>= \result -> check (result == ExclusiveOccupied) "replaced an existing file"+  BS.readFile path >>= \bytes -> check (bytes == BS.pack "complete bytes\n") "changed project bytes"+  D.removeFile path+  D.createFileLink "missing" path+  publish "replacement" >>= \result -> check (result == ExclusiveOccupied) "replaced a dangling symlink"+  D.getSymbolicLinkTarget path >>= \target -> check (target == "missing") "changed link target"+  D.removeFile path+  D.createDirectory path+  publish "replacement" >>= \result -> check (result == ExclusiveOccupied) "replaced a directory"+  D.removeDirectory path+  failed <- createFileExclusiveWith (\handle -> BS.hPutStr handle (BS.pack "partial") >> ioError (userError "injected write failure")) path+  check (case failed of ExclusiveFailure _ -> True; _ -> False) "partial failure was not reported"+  D.listDirectory root >>= \names -> check (null names) "partial write left a public or private entry"+  forM_ [1 .. 10 :: Int] $ \_ -> do+    results <- forM [1 .. 8 :: Int] $ \n -> do+      completed <- newEmptyMVar+      _ <- forkIO (publish (show n) >>= putMVar completed)+      pure completed+    outcomes <- mapM takeMVar results+    check (length (filter (== ExclusiveCreated) outcomes) == 1) "concurrent publication had multiple or no winners"+    check (length (filter (== ExclusiveOccupied) outcomes) == 7) "concurrent publication reported an unexpected error"+    D.removeFile path+  let anchor = root </> "private-anchor"+  (anchored, identity) <- createFileExclusiveAnchored (Just anchor) (\handle -> BS.hPutStr handle (BS.pack "same bytes")) path+  check (anchored == ExclusiveCreated) "anchored publication failed"+  status <- Posix.getFileStatus anchor+  check (identity == Just (fromIntegral (Posix.deviceID status), fromIntegral (Posix.fileID status))) "anchor does not retain creation identity"+  D.removeFile path+  BS.writeFile path (BS.pack "same bytes")+  replacement <- Posix.getFileStatus path+  check (Posix.fileID replacement /= Posix.fileID status) "identical replacement reused the anchored inode"+  putStrLn "Native exclusive seed creation passed: complete bytes, occupied paths, partial failure and concurrent creators."+  where+    setup = do+      tmp <- D.getTemporaryDirectory+      (path, handle) <- openBinaryTempFile tmp "seed-exclusive-native"+      hClose handle+      D.removeFile path+      D.createDirectory path+      pure path