packages feed

seihou-core 0.14.0.0 → 0.15.0.0

raw patch · 15 files changed

+354/−94 lines, 15 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Seihou.Core.Expr: settleRemovalConditions :: Map VarName VarValue -> Removal -> Removal
+ Seihou.Core.Types: ConditionalRemovalPolicies :: ManifestCapability
+ Seihou.Core.Types: [removalPolicy] :: AppliedInstanceState -> !Maybe Removal
+ Seihou.Engine.Remove: settleRemovalConditions :: Map VarName VarValue -> Removal -> Removal
+ Seihou.Manifest.Upgrade: IntroduceConditionalRemoval :: UpgradeStepAction
- Seihou.Core.Types: AppliedInstanceState :: !ModuleName -> !ParentVars -> !ArtifactOrigin -> !Maybe Text -> !Map VarName Text -> AppliedInstanceState
+ Seihou.Core.Types: AppliedInstanceState :: !ModuleName -> !ParentVars -> !ArtifactOrigin -> !Maybe Text -> !Map VarName Text -> !Maybe Removal -> AppliedInstanceState
- Seihou.Core.Types: RemovalStep :: !RemovalAction -> !Text -> !Maybe FilePath -> RemovalStep
+ Seihou.Core.Types: RemovalStep :: !RemovalAction -> !Text -> !Maybe FilePath -> !Maybe Expr -> RemovalStep

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.14.0.0+version: 0.15.0.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.
src/Seihou/Core/Application.hs view
@@ -15,6 +15,7 @@ import Data.Text qualified as T import Data.Time (UTCTime) import Seihou.Composition.Instance (ModuleInstance (..))+import Seihou.Core.Expr (settleRemovalConditions) import Seihou.Core.Types import Seihou.Manifest.Hash (hashContent) @@ -77,7 +78,8 @@           parentVars = inst ^. #parentVars,           origin = origin,           moduleVersion = modul ^. #version,-          resolvedVars = Map.map (varValueToText . (^. #value)) (Map.findWithDefault Map.empty inst resolved)+          resolvedVars = Map.map (varValueToText . (^. #value)) (Map.findWithDefault Map.empty inst resolved),+          removalPolicy = settleRemovalConditions (Map.map (^. #value) (Map.findWithDefault Map.empty inst resolved)) <$> modul ^. #removal         }  -- | Replace an existing application in place, or append a newly-applied one.
src/Seihou/Core/Expr.hs view
@@ -2,11 +2,13 @@   ( parseExpr,     renderExpr,     evalExpr,+    settleRemovalConditions,     exprRefs,   ) where  import Data.Char (isDigit)+import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Text qualified as T import Seihou.Core.Types@@ -97,6 +99,11 @@     go (ExprNot e) = not (go e)     go (ExprIsSet name) = Map.member name vars     go (ExprLit b) = b++-- | Capture the policy while resolved values still retain their declared types.+-- Persist literals, never reinterpret flattened text during a later removal.+settleRemovalConditions :: Map VarName VarValue -> Removal -> Removal+settleRemovalConditions variables spec = spec & #steps %~ map (\step -> step & #condition %~ fmap (ExprLit . evalExpr variables))  -- | Collect the variables an expression references, paired with the literal -- each is compared against via 'Eq' (if any).
src/Seihou/Core/Types.hs view
@@ -301,7 +301,8 @@ data RemovalStep = RemovalStep   { action :: !RemovalAction,     dest :: !Text,-    src :: !(Maybe FilePath)+    src :: !(Maybe FilePath),+    condition :: !(Maybe Expr)   }   deriving stock (Eq, Show, Generic) @@ -630,6 +631,7 @@   | SeedReceipts   | RecipeInputs   | RecipeInheritance+  | ConditionalRemovalPolicies   | SeedOwnerClaims   | CheckoutLocalOutputs   | CheckoutLocalCommands@@ -768,7 +770,8 @@     parentVars :: !ParentVars,     origin :: !ArtifactOrigin,     moduleVersion :: !(Maybe Text),-    resolvedVars :: !(Map VarName Text)+    resolvedVars :: !(Map VarName Text),+    removalPolicy :: !(Maybe Removal)   }   deriving stock (Eq, Show, Generic) 
src/Seihou/Dhall/Eval.hs view
@@ -104,6 +104,7 @@         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 -> evaluate (c ^. #lifecycle) >> mapM_ evaluate (c ^. #condition)) ((m ^. #commands) <> concatMap (^. #commands) (m ^. #removal))+        mapM_ (\s -> evaluate (s ^. #condition)) (concatMap (^. #steps) (m ^. #removal))         mapM_ (\p -> evaluate (p ^. #condition)) (m ^. #prompts)         pure m       Failure e -> throwIO e@@ -514,12 +515,14 @@ -- | Decoder for RemovalStep from a Dhall record. removalStepDecoder :: Decoder RemovalStep removalStepDecoder =-  record-    ( RemovalStep-        <$> field "action" removalActionDecoder-        <*> field "dest" strictText-        <*> field "src" (maybe string)-    )+  withDefaults [("when", noneText)] $+    record+      ( RemovalStep+          <$> field "action" removalActionDecoder+          <*> field "dest" strictText+          <*> field "src" (maybe string)+          <*> (parseWhen <$> field "when" (maybe strictText))+      )  -- | Decoder for RemovalAction from a Dhall Text string. --
src/Seihou/Engine/Remove.hs view
@@ -10,16 +10,17 @@     selectRecordedRemoval,     computeRemovalPlan,     executeRemoval,+    settleRemovalConditions,     buildRemovalOps,     executeRemovalOps,   ) where -import Control.Monad (foldM)+import Control.Monad (foldM, forM_, unless, when) import Data.Generics.Labels () import Data.List (nub, sortBy) import Data.Map.Strict qualified as Map-import Data.Maybe (listToMaybe, mapMaybe, maybeToList)+import Data.Maybe (isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Ord (Down (..)) import Data.Set (Set) import Data.Set qualified as Set@@ -27,6 +28,7 @@ import Data.Time (UTCTime) import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName) import Seihou.Core.Checkout+import Seihou.Core.Expr (settleRemovalConditions) import Seihou.Core.Path (pathsHaveConflictingSpelling, validateProjectRelativePath) import Seihou.Core.Seed import Seihou.Core.Types@@ -146,6 +148,17 @@ -- New step-based removal engine -- ============================================================ +settledCondition :: RemovalStep -> Bool+settledCondition step = case step ^. #condition of+  Nothing -> True+  Just (ExprLit _) -> True+  _ -> False++enabledStep :: RemovalStep -> Bool+enabledStep step = case step ^. #condition of+  Just (ExprLit False) -> False+  _ -> True+ -- | Build a list of removal operations from declared removal steps. -- Classifies remove-file targets by checking the manifest and disk state. buildRemovalOps ::@@ -157,8 +170,10 @@ buildRemovalOps manifest modName removal = do   case findApplied manifest modName of     Nothing -> pure (Left (ModuleNotApplied modName))+    Just _ | any (not . settledCondition) (removal ^. #steps) -> pure (Left (RemovalUnsafePath "condition" "when" "unsettled removal policy; run an ordinary update with this runtime first"))     Just _ -> do-      stepResults <- mapM (buildStepOp manifest modName) (removal ^. #steps)+      let active = filter enabledStep (removal ^. #steps)+      stepResults <- mapM (buildStepOp manifest modName) active       let cmdResults = map buildCommandOp (removal ^. #commands)       pure $ do         stepOps <- sequence stepResults@@ -408,46 +423,66 @@  -- | 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 recorded owning instance in the selected application")-    | otherwise -> Right Nothing-  [(app, state, qualified)] -> do-    let appId = app ^. #applicationId-        selected = Map.singleton appId (Set.singleton qualified)-        outputs = dropCheckoutOwners selected (manifest ^. #checkoutOutputs)-        keepFile record-          | record ^. #moduleName /= qualified || not (Set.member appId (record ^. #applicationIds)) = Just record-          | otherwise =-              let retained = Set.delete appId (record ^. #applicationIds)-               in if Set.null retained then Nothing else Just (record & #applicationIds .~ retained)-        keepApplication other-          | 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)) & #checkoutCommands %~ filter ((/= qualified) . (^. #moduleName)))-        applications = mapMaybe keepApplication (manifest ^. #applications)-        files = Map.mapMaybe keepFile (manifest ^. #files)-        stillNeeded applied =-          any (\other -> any (\instanceState -> instanceState ^. #name == applied ^. #name && instanceState ^. #parentVars == applied ^. #parentVars) (other ^. #instances)) applications-            || any ((== qualifiedName (ModuleInstance (applied ^. #name) (applied ^. #parentVars))) . (^. #moduleName)) (Map.elems files)-        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)]-    -- 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]))+selectRecordedRemoval manifest requested selectedApplication = do+  case candidates of+    []+      | 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+          rootRemoval = app ^. #target == AppliedModuleTarget (state ^. #name) && null (app ^. #additionalModules)+          departing = if rootRemoval then Set.fromList [qualifiedName (ModuleInstance (instanceState ^. #name) (instanceState ^. #parentVars)) | instanceState <- app ^. #instances] else Set.singleton qualified+          selected = Map.singleton appId departing+          outputs = dropCheckoutOwners selected (manifest ^. #checkoutOutputs)+          keepFile record+            | not (Set.member (record ^. #moduleName) departing) || not (Set.member appId (record ^. #applicationIds)) = Just record+            | otherwise =+                let retained = Set.delete appId (record ^. #applicationIds)+                 in if Set.null retained then Nothing else Just (record & #applicationIds .~ retained)+          keepApplication other+            | other ^. #applicationId /= appId = Just other+            | otherwise =+                let retained = filter (\instanceState -> not (Set.member (qualifiedName (ModuleInstance (instanceState ^. #name) (instanceState ^. #parentVars))) departing)) (other ^. #instances)+                 in if null retained then Nothing else Just (other & #instances .~ retained & #commandReceipts %~ Map.filter (not . (`Set.member` departing) . (^. #moduleName)) & #checkoutCommands %~ filter (not . (`Set.member` departing) . (^. #moduleName)))+          applications = mapMaybe keepApplication (manifest ^. #applications)+          files = Map.mapMaybe keepFile (manifest ^. #files)+          stillNeeded applied =+            any (\other -> any (\instanceState -> instanceState ^. #name == applied ^. #name && instanceState ^. #parentVars == applied ^. #parentVars) (other ^. #instances)) applications+              || any ((== qualifiedName (ModuleInstance (applied ^. #name) (applied ^. #parentVars))) . (^. #moduleName)) (Map.elems files)+          removeSelected applied = not (Set.member (qualifiedName (ModuleInstance (applied ^. #name) (applied ^. #parentVars))) departing) || stillNeeded applied+          candidate = manifest & #checkoutOutputs .~ outputs & #applications .~ applications & #files .~ files & #modules %~ filter removeSelected+          -- The application records dependency order; a shared module summary+          -- can move when another root is run and is not an ordering authority.+          retiring = [applied | instanceState <- app ^. #instances, applied <- manifest ^. #modules, applied ^. #name == instanceState ^. #name, applied ^. #parentVars == instanceState ^. #parentVars, not (removeSelected applied)]+          stateFor applied = listToMaybe [instanceState | instanceState <- app ^. #instances, instanceState ^. #name == applied ^. #name, instanceState ^. #parentVars == applied ^. #parentVars]+          recordedSpec applied = case stateFor applied >>= (^. #removalPolicy) of+            Just spec -> Just spec+            Nothing -> applied ^. #removal+          settle spec = spec & #steps %~ map (\step -> step & #condition .~ Nothing) . filter enabledStep+          specs = [settle spec | applied <- reverse retiring, spec <- maybeToList (recordedSpec applied)]+          custom = if null specs then Nothing else Just (Removal (concatMap (^. #steps) specs) (concatMap (^. #commands) specs))+      forM_ retiring $ \applied -> do+        when (maybe True (isNothing . (^. #removalPolicy)) (stateFor applied) && any (any (isJust . (^. #condition)) . (^. #steps)) (applied ^. #removal)) $+          Left (checkoutError "removal_policy_unknown" "this application has no accepted conditional removal policy; run its ordinary update before removing it")+        unless (all (all settledCondition . (^. #steps)) (recordedSpec applied)) $+          Left (checkoutError "removal_policy_unsettled" "removal policy has no typed recorded result; run an ordinary update with this runtime first")+        when (recordedSpec applied == Nothing && any (\record -> record ^. #moduleName == qualifiedName (ModuleInstance (applied ^. #name) (applied ^. #parentVars)) && Set.member appId (record ^. #applicationIds)) (Map.elems (manifest ^. #files))) $+          Left (checkoutError "remove_managed_policy_required" "an inherited instance owns managed files without a removal specification; add its removal policy before removing this root")+      -- Retiring seed-only metadata does not authorize forgetting managed files.+      case custom of+        Nothing+          | any (\record -> Set.member (record ^. #moduleName) departing && 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+    hasConditionalPolicy state = any (any (isJust . (^. #condition)) . (^. #steps)) (state ^. #removalPolicy) || any (\applied -> applied ^. #name == state ^. #name && applied ^. #parentVars == state ^. #parentVars && any (any (isJust . (^. #condition)) . (^. #steps)) (applied ^. #removal)) (manifest ^. #modules)     candidates =       [ (app, state, qualified)       | app <- manifest ^. #applications,@@ -455,5 +490,5 @@         state <- app ^. #instances,         let qualified = qualifiedName (ModuleInstance (state ^. #name) (state ^. #parentVars)),         requested == qualified || requested == state ^. #name,-        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))+        (app ^. #target == AppliedModuleTarget (state ^. #name) && null (app ^. #additionalModules)) || hasConditionalPolicy state || 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/Manifest/Types.hs view
@@ -16,7 +16,7 @@   ) where -import Control.Monad (forM_, unless, when)+import Control.Monad (forM, forM_, unless, when) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key@@ -34,6 +34,7 @@ import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl, sameArtifactIdentity) import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutRecords) import Seihou.Core.CommandFingerprint (checkoutCommandOperation, fingerprintCommand)+import Seihou.Core.Expr (renderExpr) import Seihou.Core.Module (isValidModuleName) import Seihou.Core.Path (validateCanonicalProjectPath, validateProjectRelativePath) import Seihou.Core.Seed (renderManifestInvariantError, seedApplicationIds, validateManifestInvariants)@@ -82,8 +83,11 @@ -- change advances this number is -- docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md. -- Schema 13 records application recipe-chain evidence; null denotes unknown history.+-- Schema 14 records conditional removal policies and single-root closure retirement.+-- Missing removal conditions remain unconditional; 13 -> 14 adds null instance+-- policies where accepted typed evidence is unknown. currentManifestVersion :: ManifestSchemaVersion-currentManifestVersion = ManifestSchemaVersion 13+currentManifestVersion = ManifestSchemaVersion 14  -- | The oldest schema the ordinary decoder reads. Anything older records -- machine-specific absolute paths and must go through the explicit@@ -99,6 +103,7 @@ minimumManifestVersion SeedReceipts = ManifestSchemaVersion 8 minimumManifestVersion RecipeInputs = ManifestSchemaVersion 12 minimumManifestVersion RecipeInheritance = ManifestSchemaVersion 13+minimumManifestVersion ConditionalRemovalPolicies = ManifestSchemaVersion 14 minimumManifestVersion SeedOwnerClaims = ManifestSchemaVersion 11 minimumManifestVersion CheckoutLocalOutputs = ManifestSchemaVersion 9 minimumManifestVersion CheckoutLocalCommands = ManifestSchemaVersion 10@@ -252,6 +257,7 @@         <*> o Aeson..:? "recipe"         <*> o Aeson..:? "blueprint"         <*> o Aeson..:? "blueprintMigrations" Aeson..!= []+    unless (manifestSupports ConditionalRemovalPolicies manifest || not (hasConditionalRemoval manifest || hasRemovalPolicy manifest)) $ fail "conditional removal policies require manifest schema 14"     case validateManifestInvariants manifest of       Left err -> fail (T.unpack (renderManifestInvariantError err))       Right () -> case validateCheckoutRecords manifest [] of@@ -359,19 +365,23 @@ legacyLocalOrigin recordedName = fromMaybe (LocalOrigin recordedName)  instance ToJSON AppliedInstanceState where-  toJSON state =-    Aeson.object $-      [ "name" .= (state ^. #name . #unModuleName),-        "origin" .= (state ^. #origin),-        "resolvedVars" .= varsToJSON (state ^. #resolvedVars)-      ]-        ++ parentVarsField (state ^. #parentVars)-        ++ maybe [] (\v -> ["version" .= v]) (state ^. #moduleVersion)-    where-      parentVarsField (ParentVars m)-        | Map.null m = []-        | otherwise = ["parentVars" .= parentVarsMapToJSON m]+  toJSON = instanceToJSON currentManifestVersion +instanceToJSON :: ManifestSchemaVersion -> AppliedInstanceState -> Aeson.Value+instanceToJSON schema state =+  Aeson.object $+    [ "name" .= (state ^. #name . #unModuleName),+      "origin" .= (state ^. #origin),+      "resolvedVars" .= varsToJSON (state ^. #resolvedVars)+    ]+      ++ ["removalPolicy" .= fmap removalToJSON (state ^. #removalPolicy) | schema >= ManifestSchemaVersion 14]+      ++ parentVarsField (state ^. #parentVars)+      ++ maybe [] (\v -> ["version" .= v]) (state ^. #moduleVersion)+  where+    parentVarsField (ParentVars m)+      | Map.null m = []+      | otherwise = ["parentVars" .= parentVarsMapToJSON m]+ instance FromJSON AppliedInstanceState where   parseJSON = Aeson.withObject "AppliedInstanceState" $ \o -> do     mParentVars <- o Aeson..:? "parentVars"@@ -384,6 +394,7 @@       <*> o .: "origin"       <*> o Aeson..:? "version"       <*> (varsFromJSON =<< o Aeson..:? "resolvedVars" Aeson..!= Aeson.object [])+      <*> (o Aeson..:? "removalPolicy" >>= traverse parseRemovalJSON)  instance ToJSON AppliedComposition where   toJSON = applicationToJSON currentManifestVersion@@ -395,7 +406,7 @@       "target" .= (composition ^. #target),       "targetOrigin" .= (composition ^. #targetOrigin),       "additionalModules" .= map (^. #unModuleName) (composition ^. #additionalModules),-      "instances" .= (composition ^. #instances),+      "instances" .= map (instanceToJSON schema) (composition ^. #instances),       "appliedAt" .= (composition ^. #appliedAt)     ]       ++ maybe [] (\v -> ["targetVersion" .= v]) (composition ^. #targetVersion)@@ -428,6 +439,11 @@   when (schema < minimumManifestVersion RecipeInheritance) $ do     legacyChain <- o Aeson..:? "recipeChain" :: Aeson.Parser (Maybe Aeson.Value)     unless (legacyChain == Nothing) (fail "recipe chain requires manifest schema 13")+  rawInstances <- o Aeson..:? "instances" Aeson..!= []+  instances <- forM rawInstances $ \value -> do+    when (schema >= minimumManifestVersion ConditionalRemovalPolicies) $+      Aeson.withObject "AppliedInstanceState" (\fields -> unless (KM.member "removalPolicy" fields) (fail "schema 14 instance requires removalPolicy (null for unknown legacy policy)")) value+    parseJSON value   composition <-     AppliedComposition       <$> (ApplicationId <$> o .: "applicationId")@@ -437,7 +453,7 @@       <*> (map ModuleName <$> o Aeson..:? "additionalModules" Aeson..!= [])       <*> o Aeson..:? "namespace"       <*> o Aeson..:? "context"-      <*> o Aeson..:? "instances" Aeson..!= []+      <*> pure instances       <*> (commandReceiptsFromJSON =<< o Aeson..:? "commandReceipts" Aeson..!= Aeson.object [])       <*> pure commands       <*> (if schema >= ManifestSchemaVersion 12 then traverse varsFromJSON =<< o .: "recipeInputs" else pure Nothing)@@ -645,6 +661,16 @@   m <- parseJSON v :: Aeson.Parser (Map Text Text)   pure (Map.mapKeys VarName m) +-- | All accepted policies, including per-application instance receipts.+recordedRemovals :: Manifest -> [Removal]+recordedRemovals manifest = [spec | applied <- manifest ^. #modules, spec <- maybe [] pure (applied ^. #removal)] <> [spec | app <- manifest ^. #applications, state <- app ^. #instances, spec <- maybe [] pure (state ^. #removalPolicy)]++hasRemovalPolicy :: Manifest -> Bool+hasRemovalPolicy manifest = or [isJust (state ^. #removalPolicy) | app <- manifest ^. #applications, state <- app ^. #instances]++hasConditionalRemoval :: Manifest -> Bool+hasConditionalRemoval manifest = or [isJust (step ^. #condition) | applied <- manifest ^. #modules, spec <- maybe [] pure (applied ^. #removal), step <- spec ^. #steps]+ -- | Encode a Removal to JSON. removalToJSON :: Removal -> Aeson.Value removalToJSON r =@@ -660,6 +686,7 @@       "dest" .= (s ^. #dest)     ]       ++ maybe [] (\p -> ["src" .= p]) (s ^. #src)+      ++ maybe [] (\expr -> ["when" .= case expr of ExprLit value -> Aeson.Bool value; _ -> Aeson.String (renderExpr expr)]) (s ^. #condition)  removalActionToText :: RemovalAction -> Text removalActionToText RemoveFileAction = "remove-file"@@ -685,6 +712,7 @@     <$> (parseRemovalActionText =<< o .: "action")     <*> o .: "dest"     <*> o Aeson..:? "src"+    <*> (fmap ExprLit <$> o Aeson..:? "when")  parseRemovalActionText :: Text -> Aeson.Parser RemovalAction parseRemovalActionText "remove-file" = pure RemoveFileAction@@ -903,6 +931,9 @@     <> ["recipe inputs require manifest schema 12" | not (manifestSupports RecipeInputs manifest), any (isJust . (^. #recipeInputs)) (manifest ^. #applications)]     <> ["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)+    <> ["conditional removal policies must record a typed boolean result" | spec <- recordedRemovals manifest, step <- spec ^. #steps, Just expr <- [step ^. #condition], case expr of ExprLit _ -> False; _ -> True]+    <> ["instance removal policies require manifest schema 14" | not (manifestSupports ConditionalRemovalPolicies manifest), hasRemovalPolicy manifest]+    <> ["conditional removal policies require manifest schema 14" | not (manifestSupports ConditionalRemovalPolicies manifest), hasConditionalRemoval manifest]     <> ["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))]
src/Seihou/Manifest/Upgrade.hs view
@@ -33,7 +33,7 @@   ) where -import Control.Monad (foldM, forM, unless, when)+import Control.Monad (foldM, forM, forM_, unless, when) import Data.Aeson qualified as Aeson import Data.Aeson.Key qualified as Key import Data.Aeson.KeyMap qualified as KeyMap@@ -60,6 +60,7 @@   | AddEmptySeedReceipts   | AddEmptyCheckoutOutputs   | AddUnknownRecipeInputs+  | IntroduceConditionalRemoval   | AddUnknownRecipeChain   | MakeSeedOwnershipExplicit   | AddEmptyCheckoutCommands@@ -126,7 +127,8 @@     step 9 AddEmptyCheckoutCommands "repeatable checkout commands",     step 10 MakeSeedOwnershipExplicit "explicit seed instance ownership",     step 11 AddUnknownRecipeInputs "recipe inputs",-    step 12 AddUnknownRecipeChain "recipe inheritance provenance"+    step 12 AddUnknownRecipeChain "recipe inheritance provenance",+    step 13 IntroduceConditionalRemoval "conditional removal policies and recorded root retirement"   ]   where     step from stepAction =@@ -135,6 +137,7 @@ upgradeStepKind :: UpgradeStepAction -> UpgradeStepKind upgradeStepKind AddUnknownRecipeInputs = LosslessUpgrade upgradeStepKind AddUnknownRecipeChain = LosslessUpgrade+upgradeStepKind IntroduceConditionalRemoval = LosslessUpgrade upgradeStepKind StampVersion = LosslessUpgrade upgradeStepKind ConvertMachineLocalPaths = InferenceBearingUpgrade upgradeStepKind MakeSharedWriteEvidenceExplicit = LosslessUpgrade@@ -196,6 +199,9 @@   unless (actual == upgradeStep ^. #fromVersion) $     Left (StepVersionMismatch upgradeStep actual)   transformed <- case upgradeStep ^. #action of+    IntroduceConditionalRemoval -> do+      validateUnconditionalRemovals actual document+      addUnknownRemovalPolicies actual document     AddUnknownRecipeChain -> case document of       Aeson.Object fields -> do         apps <- case KeyMap.lookup "applications" fields of@@ -350,3 +356,60 @@     add (Aeson.Object fields) = Aeson.Object (KeyMap.insert "recipeInputs" Aeson.Null fields)     add other = other addInputs other = other++-- Schema 13 has no conditional-removal evidence. Do not legitimize a future field+-- merely by stamping its document with a newer version; preserve all unrelated keys.+validateUnconditionalRemovals :: ManifestSchemaVersion -> Aeson.Value -> Either ManifestUpgradeError ()+validateUnconditionalRemovals version = \case+  Aeson.Object fields -> do+    modules <- values (fromMaybe (Aeson.Array mempty) (KeyMap.lookup "modules" fields))+    forM_ modules $ \case+      Aeson.Object modul -> case KeyMap.lookup "removal" modul of+        Nothing -> Right ()+        Just Aeson.Null -> Right ()+        Just (Aeson.Object removal) -> do+          steps <- values (fromMaybe (Aeson.Array mempty) (KeyMap.lookup "steps" removal))+          forM_ steps $ \case+            Aeson.Object step -> case KeyMap.lookup "when" step of+              Nothing -> Right ()+              Just Aeson.Null -> Right ()+              _ -> bad "schema 13 cannot contain conditional removal policies"+            _ -> bad "removal step must be an object"+        _ -> bad "removal must be an object"+      _ -> bad "module must be an object"+  _ -> bad "manifest must be an object"+  where+    bad :: Text -> Either ManifestUpgradeError a+    bad = Left . MalformedDocument version+    values value = case Aeson.fromJSON value of+      Aeson.Success xs -> Right (xs :: [Aeson.Value])+      Aeson.Error err -> bad (T.pack err)++-- The old reader never accepted typed per-application policy results. Preserve+-- unknown fields and explicitly record that historical evidence is unavailable.+addUnknownRemovalPolicies :: ManifestSchemaVersion -> Aeson.Value -> Either ManifestUpgradeError Aeson.Value+addUnknownRemovalPolicies version (Aeson.Object fields) = case KeyMap.lookup "applications" fields of+  Nothing -> Right (Aeson.Object fields)+  Just value -> do+    apps <- values value+    upgraded <- forM apps $ \case+      Aeson.Object app -> case KeyMap.lookup "instances" app of+        Nothing -> Right (Aeson.Object app)+        Just states -> do+          instances <- values states+          converted <- forM instances $ \case+            Aeson.Object state -> case KeyMap.lookup "removalPolicy" state of+              Nothing -> Right (Aeson.Object (KeyMap.insert "removalPolicy" Aeson.Null state))+              Just Aeson.Null -> Right (Aeson.Object state)+              _ -> bad "schema 13 cannot contain per-application removal policy evidence"+            _ -> bad "instance must be an object"+          Right (Aeson.Object (KeyMap.insert "instances" (Aeson.toJSON converted) app))+      _ -> bad "application must be an object"+    Right (Aeson.Object (KeyMap.insert "applications" (Aeson.toJSON upgraded) fields))+  where+    bad :: Text -> Either ManifestUpgradeError a+    bad = Left . MalformedDocument version+    values value = case Aeson.fromJSON value of+      Aeson.Success xs -> Right (xs :: [Aeson.Value])+      Aeson.Error err -> bad (T.pack err)+addUnknownRemovalPolicies version _ = Left (MalformedDocument version "manifest must be an object")
test/Seihou/Core/ApplicationSpec.hs view
@@ -1,6 +1,6 @@ module Seihou.Core.ApplicationSpec (tests) where -import Control.Lens ((&), (?~), (^.))+import Control.Lens ((&), (.~), (?~), (^.)) import Control.Monad (forM_) import Data.Generics.Labels () import Data.Map.Strict qualified as Map@@ -115,6 +115,17 @@       map (^. #parentVars) (composition ^. #instances) `shouldBe` [pv1, pv2]       map (^. #resolvedVars) (composition ^. #instances)         `shouldBe` [Map.singleton "skill.name" "exec-plan", Map.singleton "skill.name" "master-plan"]++    it "settles removal while Boolean and text values retain their declared types" $ do+      let inst = ModuleInstance "dep" emptyParentVars+          condition = ExprEq (VarName "flag") (VBool True)+          policy = Removal [RemovalStep RemoveFileAction "starter.txt" Nothing (Just condition)] []+          modul = mkModule "dep" Nothing & #removal .~ Just policy+          build value = buildAppliedComposition moduleTarget (LocalOrigin "root") Nothing [] Nothing Nothing [(inst, modul, LocalOrigin "dep")] (Map.singleton inst (Map.singleton "flag" (mkResolved "flag" value))) fixedTime+          expected result = [Just (Removal [RemovalStep RemoveFileAction "starter.txt" Nothing (Just (ExprLit result))] [])]+      map (^. #removalPolicy) (build (VBool True) ^. #instances) `shouldBe` expected True+      map (^. #removalPolicy) (build (VText "true") ^. #instances) `shouldBe` expected False+      map (^. #resolvedVars) (build (VBool True) ^. #instances) `shouldBe` map (^. #resolvedVars) (build (VText "true") ^. #instances)      it "keeps identity independent of versions, source paths, and resolved values" $ do       let inst = ModuleInstance "dep" emptyParentVars
test/Seihou/Core/SeedSpec.hs view
@@ -39,7 +39,7 @@           []           Nothing           Nothing-          [AppliedInstanceState name emptyParentVars (LocalOrigin (name ^. #unModuleName)) Nothing Map.empty | name <- ["demo", "second"]]+          [AppliedInstanceState name emptyParentVars (LocalOrigin (name ^. #unModuleName)) Nothing Map.empty Nothing | name <- ["demo", "second"]]           Map.empty           []           Nothing@@ -123,7 +123,7 @@       checkSeedSteps patched `shouldSatisfy` (not . null)       checkSeedSteps (seed & #steps %~ map (#strategy .~ Structured)) `shouldSatisfy` (not . null)       checkSeedSteps (seed & #steps %~ (<> map (#lifecycle .~ Managed) (seed ^. #steps))) `shouldSatisfy` (not . null)-      checkSeedSteps (seed & #removal ?~ Removal [RemovalStep RemoveFileAction "b" Nothing] []) `shouldSatisfy` (not . null)+      checkSeedSteps (seed & #removal ?~ Removal [RemovalStep RemoveFileAction "b" Nothing Nothing] []) `shouldSatisfy` (not . null)       TIO.writeFile (dir </> "module.dhall") (source ", lifecycle = Some \"sede\"")       invalid <- evalModuleFromFile (dir </> "module.dhall")       invalid `shouldSatisfy` isLeft
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 [] Nothing Nothing now+    app = AppliedComposition (ApplicationId "a") (AppliedModuleTarget "demo") (LocalOrigin "demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (LocalOrigin "demo") (Just "1") Map.empty Nothing] Map.empty [] Nothing Nothing now     record source = CheckoutOutputRecord (DirectorySymlink source) (Map.singleton (ApplicationId "a") (Set.singleton "demo"))  withFixture :: (FilePath -> IO a) -> IO a
test/Seihou/Engine/RemoveSpec.hs view
@@ -1,6 +1,6 @@ module Seihou.Engine.RemoveSpec (tests) where -import Control.Lens ((&), (.~), (^.))+import Control.Lens ((%~), (&), (.~), (^.)) import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Set qualified as Set@@ -81,7 +81,7 @@ 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 [] Nothing Nothing fixedTime+        app = AppliedComposition appId (AppliedModuleTarget otherMod) (LocalOrigin "test-module") Nothing [] Nothing Nothing [AppliedInstanceState name emptyParentVars (LocalOrigin text) Nothing Map.empty Nothing | (name, text) <- [(modName, "test-module"), (otherMod, "other-module")]] Map.empty [] Nothing Nothing 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@@ -90,7 +90,7 @@       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]] []+      let removal = Removal [RemovalStep action "seed.txt" (Just "rewrite.dhall") Nothing | 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@@ -102,6 +102,60 @@       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 "recorded root closure and conditional removal" $ do+    let appId = ApplicationId "root"+        variables = Map.singleton (VarName "base.starters") "false"+        rootApp = AppliedComposition appId (AppliedModuleTarget modName) (LocalOrigin "root") Nothing [] Nothing Nothing [AppliedInstanceState name emptyParentVars (LocalOrigin "fixture") Nothing variables (Just (if name == otherMod then settleRemovalConditions (Map.singleton (VarName "base.starters") (VBool False)) baseSpec else rootSpec)) | name <- [otherMod, modName]] Map.empty [] Nothing Nothing fixedTime+        baseSpec = Removal [RemovalStep RemoveFileAction "base.txt" Nothing Nothing, RemovalStep RemoveFileAction "package.json" Nothing (Just (ExprEq (VarName "base.starters") (VBool True)))] []+        rootSpec = Removal [RemovalStep RemoveFileAction "client.txt" Nothing Nothing] []+        applied name spec = AppliedModule name emptyParentVars (LocalOrigin "fixture") Nothing fixedTime (Just spec)+        file name owners = FileRecord (hashContent "bytes") name Template fixedTime Nothing owners SharedWriteUnknown+        manifest =+          (emptyManifest fixedTime)+            & #applications .~ [rootApp]+            & #modules .~ [applied otherMod (settleRemovalConditions (Map.singleton (VarName "base.starters") (VBool False)) baseSpec), applied modName rootSpec]+            & #files .~ Map.fromList [("base.txt", file otherMod (Set.singleton appId)), ("client.txt", file modName (Set.singleton appId))]+            & #seeds .~ Map.singleton "package.json" (SeedRecord modName (KnownSeedOwners (Map.singleton appId (Set.singleton modName))) SeedCreated fixedTime)+    it "retires an unshared inherited closure and uses recorded per-instance conditions" $ do+      Right (Just selected) <- pure (selectRecordedRemoval manifest modName Nothing)+      selected ^. #candidateManifest . #applications `shouldBe` []+      selected ^. #candidateManifest . #modules `shouldBe` []+      selected ^. #candidateManifest . #files `shouldBe` Map.empty+      fmap (map (^. #dest) . (^. #steps)) (selected ^. #removal) `shouldBe` Just ["client.txt", "base.txt"]+    it "uses application dependency order when shared module summaries were reordered" $ do+      Right (Just selected) <- pure (selectRecordedRemoval (manifest & #modules %~ reverse) modName Nothing)+      fmap (map (^. #dest) . (^. #steps)) (selected ^. #removal) `shouldBe` Just ["client.txt", "base.txt"]+    it "selects a recorded managed root even without seed or checkout receipts" $ do+      Right (Just selected) <- pure (selectRecordedRemoval (manifest & #seeds .~ Map.empty) modName Nothing)+      selected ^. #candidateManifest . #applications `shouldBe` []+      selected ^. #candidateManifest . #modules `shouldBe` []+    it "keeps an inherited module claimed by another application" $ do+      let otherId = ApplicationId "survivor"+          survivor = rootApp & #applicationId .~ otherId & #target .~ AppliedModuleTarget otherMod & #instances %~ filter ((== otherMod) . (^. #name))+          shared = manifest & #applications %~ (<> [survivor]) & #files %~ Map.adjust (\record -> record & #applicationIds %~ Set.insert otherId) "base.txt"+      Right (Just selected) <- pure (selectRecordedRemoval shared modName Nothing)+      fmap (map (^. #dest) . (^. #steps)) (selected ^. #removal) `shouldBe` Just ["client.txt"]+      map (^. #name) (selected ^. #candidateManifest . #modules) `shouldBe` [otherMod]+      Map.keys (selected ^. #candidateManifest . #files) `shouldBe` ["base.txt"]+    it "uses the departing application's receipt even when a shared module summary differs" $ do+      let laterSummary = manifest & #modules %~ map (\entry -> if entry ^. #name == otherMod then entry & #removal .~ Just (settleRemovalConditions (Map.singleton (VarName "base.starters") (VBool True)) baseSpec) else entry)+      Right (Just selected) <- pure (selectRecordedRemoval laterSummary modName Nothing)+      fmap (map (^. #dest) . (^. #steps)) (selected ^. #removal) `shouldBe` Just ["client.txt", "base.txt"]+    it "requires ordinary adoption when a conditional legacy application has no receipt" $ do+      let unknown = manifest & #applications %~ map (\app -> app & #instances %~ map (\state -> state & #removalPolicy .~ Nothing))+      selectRecordedRemoval unknown modName Nothing `shouldSatisfy` either (const True) (const False)+    it "preserves type-sensitive conditions and refuses unresolved policy" $ do+      let fs = mkFS [("package.json", "bytes"), ("base.txt", "bytes")]+          legacy = mkManifestWithRemoval baseSpec [("package.json", "bytes"), ("base.txt", "bytes")]+          settle value = settleRemovalConditions (Map.singleton (VarName "base.starters") value) baseSpec+      Right yes <- pure (runBuildOps fs legacy modName (settle (VBool True)))+      Right no <- pure (runBuildOps fs legacy modName (settle (VBool False)))+      Right text <- pure (runBuildOps fs legacy modName (settle (VText "true")))+      length (yes ^. #ops) `shouldBe` 2+      length (no ^. #ops) `shouldBe` 1+      length (text ^. #ops) `shouldBe` 1+      runBuildOps fs legacy modName baseSpec `shouldSatisfy` either (const True) (const False)+   describe "computeRemovalPlan" $ do     it "returns ModuleNotApplied when module is not in manifest" $ do       let manifest = emptyManifest fixedTime@@ -227,7 +281,7 @@       result `shouldBe` Left (ModuleNotApplied modName)      it "builds DeleteFileOp with RFSafe for unchanged files" $ do-      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [("README.md", "hello")]           fs = mkFS [("README.md", "hello")]           result = runBuildOps fs manifest modName removal@@ -236,7 +290,7 @@         Right plan -> (plan ^. #ops) `shouldBe` [DeleteFileOp "README.md" RFSafe]      it "builds DeleteFileOp with RFConflict for modified files" $ do-      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [("README.md", "original")]           fs = mkFS [("README.md", "user changed this")]           result = runBuildOps fs manifest modName removal@@ -245,7 +299,7 @@         Right plan -> (plan ^. #ops) `shouldBe` [DeleteFileOp "README.md" RFConflict]      it "builds DeleteFileOp with RFGone for already-deleted files" $ do-      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [("README.md", "hello")]           result = runBuildOps emptyFS manifest modName removal       case result of@@ -253,7 +307,7 @@         Right plan -> (plan ^. #ops) `shouldBe` [DeleteFileOp "README.md" RFGone]      it "builds StripSectionOp for remove-section steps" $ do-      let removal = Removal [RemovalStep RemoveSectionAction ".gitignore" Nothing] []+      let removal = Removal [RemovalStep RemoveSectionAction ".gitignore" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [(".gitignore", "content")]           fs = mkFS [(".gitignore", "content")]           result = runBuildOps fs manifest modName removal@@ -270,7 +324,7 @@         Right plan -> (plan ^. #ops) `shouldBe` [RemovalCommandOp "cabal clean" Nothing]      it "rejects a removal step destination with a parent directory segment" $ do-      let removal = Removal [RemovalStep RemoveFileAction "../outside" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "../outside" Nothing Nothing] []           manifest = mkManifestWithRemoval removal []           fs = mkFS [("../outside", "do not delete")]           result = runBuildOps fs manifest modName removal@@ -298,7 +352,7 @@     it "combines steps and commands in order" $ do       let removal =             Removal-              [RemovalStep RemoveFileAction "a.txt" Nothing, RemovalStep RemoveSectionAction ".gitignore" Nothing]+              [RemovalStep RemoveFileAction "a.txt" Nothing Nothing, RemovalStep RemoveSectionAction ".gitignore" Nothing Nothing]               [Command "echo done" Nothing Nothing ApplicationCommand]           manifest = mkManifestWithRemoval removal [("a.txt", "aaa")]           fs = mkFS [("a.txt", "aaa"), (".gitignore", "stuff")]@@ -313,7 +367,7 @@    describe "executeRemovalOps" $ do     it "deletes files with DeleteFileOp" $ do-      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "README.md" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [("README.md", "hello")]           fs = mkFS [("README.md", "hello")]           plan = ExecutedRemovalPlan modName [DeleteFileOp "README.md" RFSafe]@@ -321,7 +375,7 @@       Map.member "README.md" (finalFS ^. #files) `shouldBe` False      it "preserves files in keep-set for DeleteFileOp" $ do-      let removal = Removal [RemovalStep RemoveFileAction "a.txt" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "a.txt" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [("a.txt", "aaa")]           fs = mkFS [("a.txt", "modified")]           plan = ExecutedRemovalPlan modName [DeleteFileOp "a.txt" RFConflict]@@ -356,7 +410,7 @@       Map.lookup "file.txt" (finalFS ^. #files) `shouldBe` Just content      it "removes module from manifest after all steps" $ do-      let removal = Removal [RemovalStep RemoveFileAction "a.txt" Nothing] []+      let removal = Removal [RemovalStep RemoveFileAction "a.txt" Nothing Nothing] []           manifest = mkManifestWithRemoval removal [("a.txt", "aaa")]           fs = mkFS [("a.txt", "aaa")]           plan = ExecutedRemovalPlan modName [DeleteFileOp "a.txt" RFSafe]
test/Seihou/Engine/SeedTransactionSpec.hs view
@@ -29,7 +29,7 @@ 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 [] Nothing Nothing now+      app = AppliedComposition appId (AppliedModuleTarget "seeder") (LocalOrigin "seeder") Nothing [] Nothing Nothing [AppliedInstanceState "seeder" emptyParentVars (LocalOrigin "seeder") Nothing Map.empty Nothing] Map.empty [] Nothing Nothing now       manifest = emptyManifest now & #applications .~ [app]       claims = Map.singleton appId (Set.singleton "seeder")       createAction = SeedAction "seed.txt" "seeder" claims SeedWrite (Just "seed bytes\n")
test/Seihou/Manifest/TypesSpec.hs view
@@ -96,14 +96,16 @@                        parentVars = emptyParentVars,                        origin = ProjectOrigin ".seihou/modules/docs",                        moduleVersion = Just "0.1.0",-                       resolvedVars = Map.empty+                       resolvedVars = Map.empty,+                       removalPolicy = Nothing                      },                    AppliedInstanceState                      { name = ModuleName "scratch",                        parentVars = emptyParentVars,                        origin = LocalOrigin "scratch",                        moduleVersion = Nothing,-                       resolvedVars = Map.empty+                       resolvedVars = Map.empty,+                       removalPolicy = Nothing                      }                  ],                commandReceipts = Map.empty,@@ -137,7 +139,7 @@                removal =                  Just                    ( Removal-                       [RemovalStep RemoveFileAction "flake.nix" (Just "files/flake.nix")]+                       [RemovalStep RemoveFileAction "flake.nix" (Just "files/flake.nix") (Just (ExprLit True))]                        [Command "cabal clean" (Just "backend") Nothing ApplicationCommand]                    )              }@@ -297,7 +299,7 @@    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 Nothing Nothing fixedTime+        application commands = AppliedComposition (ApplicationId "setup") (AppliedModuleTarget "demo") (ProjectOrigin "modules/demo") (Just "1") [] Nothing Nothing [AppliedInstanceState "demo" emptyParentVars (ProjectOrigin "modules/demo") (Just "1") Map.empty Nothing] Map.empty commands Nothing Nothing fixedTime         manifest commands = emptyManifest fixedTime & #applications .~ [application commands]         mutateApp transform value = case value of           Aeson.Object fields -> case KeyMap.lookup "applications" fields of@@ -337,6 +339,40 @@       (m ^. #files) `shouldBe` Map.empty       (m ^. #blueprintMigrations) `shouldBe` [] +  describe "conditional removal policy codec" $ do+    let policy = Removal [RemovalStep RemoveFileAction "package.json" Nothing (Just (ExprLit True))] []+        applied = AppliedModule (ModuleName "base") emptyParentVars (LocalOrigin "base") Nothing fixedTime (Just policy)+        manifest = emptyManifest fixedTime & #modules .~ [applied]+    it "roundtrips the condition at schema 14" $+      manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest+    it "roundtrips per-application policy and rejects missing or unresolved accepted evidence" $ do+      let state = AppliedInstanceState "base" emptyParentVars (LocalOrigin "base") Nothing Map.empty (Just policy)+          app = AppliedComposition (ApplicationId "base") (AppliedModuleTarget "base") (LocalOrigin "base") Nothing [] Nothing Nothing [state] Map.empty [] Nothing Nothing fixedTime+          recorded = manifest & #applications .~ [app]+          alterInstances f (Aeson.Object fields) = Aeson.Object (update "applications" (mapArray (alterApp f)) fields)+          alterInstances _ value = value+          mapArray f (Aeson.Array values) = Aeson.Array (fmap f values)+          mapArray _ value = value+          alterApp f (Aeson.Object fields) = Aeson.Object (update "instances" (mapArray (\case Aeson.Object fields' -> Aeson.Object (f fields'); value -> value)) fields)+          alterApp _ value = value+          update key f fields = maybe fields (\value -> KeyMap.insert key (f value) fields) (KeyMap.lookup key fields)+          withoutPolicy = alterInstances (KeyMap.delete "removalPolicy") (Aeson.toJSON recorded)+          unresolved = recorded & #applications %~ map (\entry -> entry & #instances %~ map (\instanceState -> instanceState & #removalPolicy .~ Just (Removal [RemovalStep RemoveFileAction "starter" Nothing (Just (ExprEq "flag" (VBool True)))] [])))+      manifestFromJSON (manifestToJSON recorded) `shouldBe` Right recorded+      manifestFromJSON (Aeson.encode withoutPolicy) `shouldSatisfy` either (const True) (const False)+      manifestEncodingLoss unresolved `shouldSatisfy` (not . null)+      manifestFromJSON (manifestToJSON unresolved) `shouldSatisfy` either (const True) (const False)++    it "rejects conditional evidence in an older schema instead of ignoring it" $ do+      let old = manifest & #version .~ ManifestSchemaVersion 13+      manifestEncodingLoss old `shouldSatisfy` (not . null)+      manifestFromJSON (manifestToJSON old) `shouldSatisfy` either (const True) (const False)++    it "rejects an unresolved expression instead of reinterpreting saved text" $ do+      let unresolved = manifest & #modules %~ map (\entry -> entry & #removal %~ fmap (\spec -> spec & #steps %~ map (\step -> step & #condition .~ Just (ExprEq (VarName "base.starters") (VBool True)))))+      manifestEncodingLoss unresolved `shouldSatisfy` (not . null)+      manifestFromJSON (manifestToJSON unresolved) `shouldSatisfy` either (const True) (const False)+   describe "ArtifactOrigin" $ do     it "roundtrips a remote origin carrying a repository name" $ do       let origin = RemoteOrigin "https://github.com/shinzui/seihou-modules.git" "haskell-base" (Just "seihou-modules")@@ -543,8 +579,8 @@                 namespace = Just "planning",                 context = Just "work",                 instances =-                  [ AppliedInstanceState (ModuleName "link-skill") pv1 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "exec-plan"),-                    AppliedInstanceState (ModuleName "link-skill") pv2 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "master-plan")+                  [ AppliedInstanceState (ModuleName "link-skill") pv1 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "exec-plan") Nothing,+                    AppliedInstanceState (ModuleName "link-skill") pv2 (LocalOrigin "link-skill") (Just "1") (Map.singleton (VarName "skill.name") "master-plan") Nothing                   ],                 commandReceipts = Map.singleton fingerprint receipt,                 checkoutCommands = [],
test/Seihou/Manifest/UpgradeSpec.hs view
@@ -63,6 +63,21 @@  spec :: Spec spec = do+  it "upgrades unconditional schema-13 removal without changing producer fields" $ do+    let document = Aeson.object ["version" Aeson..= (13 :: Int), "producerField" Aeson..= True, "modules" Aeson..= [Aeson.object ["removal" Aeson..= Aeson.object ["steps" Aeson..= [Aeson.object ["action" Aeson..= ("remove-file" :: String), "dest" Aeson..= ("package.json" :: String)]]]]]]+    case upgradeDocumentLosslessly (v 14) document of+      Right (_, upgraded) -> upgraded `shouldBe` setDocumentSchemaVersion (v 14) document+      Left err -> expectationFailure (show err)+  it "adds unknown instance removal evidence losslessly without manufacturing an outcome" $ do+    let state = Aeson.object ["producerInstance" Aeson..= True]+        document = Aeson.object ["version" Aeson..= (13 :: Int), "applications" Aeson..= [Aeson.object ["instances" Aeson..= [state], "producerApplication" Aeson..= True]]]+        expected = Aeson.object ["version" Aeson..= (14 :: Int), "applications" Aeson..= [Aeson.object ["instances" Aeson..= [Aeson.object ["producerInstance" Aeson..= True, "removalPolicy" Aeson..= Aeson.Null]], "producerApplication" Aeson..= True]]]+    fmap snd (upgradeDocumentLosslessly (v 14) document) `shouldBe` Right expected+    let forged = Aeson.object ["version" Aeson..= (13 :: Int), "applications" Aeson..= [Aeson.object ["instances" Aeson..= [Aeson.object ["removalPolicy" Aeson..= Aeson.object []]]]]]+    upgradeDocumentLosslessly (v 14) forged `shouldSatisfy` either (const True) (const False)+  it "refuses conditional evidence in a schema-13 document" $ do+    let document = Aeson.object ["version" Aeson..= (13 :: Int), "modules" Aeson..= [Aeson.object ["removal" Aeson..= Aeson.object ["steps" Aeson..= [Aeson.object ["when" Aeson..= ("true" :: String)]]]]]]+    upgradeDocumentLosslessly (v 14) document `shouldSatisfy` either (const True) (const False)   it "upgrades schema 12 with unknown recipe chain history and preserves unknown raw fields" $ do     let document = Aeson.object ["version" Aeson..= (12 :: Int), "futureTop" Aeson..= True, "applications" Aeson..= [Aeson.object ["producerOwned" Aeson..= True]]]     case upgradeDocumentLosslessly (v 13) document of