packages feed

seihou-core 0.9.0.0 → 0.10.0.0

raw patch · 49 files changed

+2537/−349 lines, 49 filesdep ~aesondep ~processPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: aeson, process

API changes (from Hackage documentation)

- Seihou.Core.Types: [additiveOnly] :: FileRecord -> !Bool
- Seihou.Engine.UpdateTransaction: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Engine.UpdateTransaction.JournalEntry
- Seihou.Manifest.Types: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.Types.FileRecord
- Seihou.Manifest.Types: instance Data.Aeson.Types.ToJSON.ToJSON Seihou.Core.Types.FileRecord
+ Seihou.Core.ArtifactIdentity: isMachineLocalOriginUrl :: Text -> Bool
+ Seihou.Core.Checkout: AlreadyAbsent :: CheckoutAction
+ Seihou.Core.Checkout: ApplyDeclaration :: CheckoutPolicy
+ Seihou.Core.Checkout: CheckoutEntry :: !FilePath -> !Maybe CheckoutOutputSpec -> !Maybe CheckoutOutputSpec -> !CheckoutObservation -> !CheckoutAction -> ![CheckoutError] -> ![Text] -> CheckoutEntry
+ Seihou.Core.Checkout: CheckoutError :: !Text -> !Text -> CheckoutError
+ Seihou.Core.Checkout: CheckoutObservation :: !PathEntry -> !Bool -> ![CheckoutError] -> !Bool -> !Bool -> CheckoutObservation
+ Seihou.Core.Checkout: CheckoutPlan :: [CheckoutEntry] -> CheckoutPlan
+ Seihou.Core.Checkout: CreateLink :: CheckoutAction
+ Seihou.Core.Checkout: PreserveModified :: CheckoutAction
+ Seihou.Core.Checkout: Rebuild :: CheckoutIntent
+ Seihou.Core.Checkout: Reconcile :: CheckoutIntent
+ Seihou.Core.Checkout: RefusedLink :: CheckoutAction
+ Seihou.Core.Checkout: ReplaceLink :: CheckoutAction
+ Seihou.Core.Checkout: RetireLink :: CheckoutAction
+ Seihou.Core.Checkout: SyncIgnoredOnly :: CheckoutPolicy
+ Seihou.Core.Checkout: UnchangedLink :: CheckoutAction
+ Seihou.Core.Checkout: [action] :: CheckoutEntry -> !CheckoutAction
+ Seihou.Core.Checkout: [candidate] :: CheckoutEntry -> !Maybe CheckoutOutputSpec
+ Seihou.Core.Checkout: [code] :: CheckoutError -> !Text
+ Seihou.Core.Checkout: [dest] :: CheckoutEntry -> !FilePath
+ Seihou.Core.Checkout: [entries] :: CheckoutPlan -> [CheckoutEntry]
+ Seihou.Core.Checkout: [entry] :: CheckoutObservation -> !PathEntry
+ Seihou.Core.Checkout: [errors] :: CheckoutEntry -> ![CheckoutError]
+ Seihou.Core.Checkout: [gitAvailable] :: CheckoutObservation -> !Bool
+ Seihou.Core.Checkout: [ignored] :: CheckoutObservation -> !Bool
+ Seihou.Core.Checkout: [message] :: CheckoutError -> !Text
+ Seihou.Core.Checkout: [observation] :: CheckoutEntry -> !CheckoutObservation
+ Seihou.Core.Checkout: [previous] :: CheckoutEntry -> !Maybe CheckoutOutputSpec
+ Seihou.Core.Checkout: [problems] :: CheckoutObservation -> ![CheckoutError]
+ Seihou.Core.Checkout: [sourceDirectory] :: CheckoutObservation -> !Bool
+ Seihou.Core.Checkout: [warnings] :: CheckoutEntry -> ![Text]
+ Seihou.Core.Checkout: checkoutApplicable :: CheckoutPlan -> Bool
+ Seihou.Core.Checkout: checkoutClaims :: ApplicationId -> [Operation] -> Either CheckoutError (Map FilePath CheckoutOutputRecord)
+ Seihou.Core.Checkout: checkoutError :: Text -> Text -> CheckoutError
+ Seihou.Core.Checkout: checkoutSource :: CheckoutOutputSpec -> FilePath
+ Seihou.Core.Checkout: data CheckoutAction
+ Seihou.Core.Checkout: data CheckoutEntry
+ Seihou.Core.Checkout: data CheckoutError
+ Seihou.Core.Checkout: data CheckoutIntent
+ Seihou.Core.Checkout: data CheckoutObservation
+ Seihou.Core.Checkout: data CheckoutPolicy
+ Seihou.Core.Checkout: dropCheckoutOwners :: Map ApplicationId (Set ModuleName) -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord
+ Seihou.Core.Checkout: expectedTarget :: FilePath -> CheckoutOutputSpec -> FilePath
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutAction
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutEntry
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutError
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutIntent
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutObservation
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutPlan
+ Seihou.Core.Checkout: instance GHC.Classes.Eq Seihou.Core.Checkout.CheckoutPolicy
+ Seihou.Core.Checkout: instance GHC.Classes.Ord Seihou.Core.Checkout.CheckoutAction
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutAction
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutEntry
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutError
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutIntent
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutObservation
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutPlan
+ Seihou.Core.Checkout: instance GHC.Internal.Generics.Generic Seihou.Core.Checkout.CheckoutPolicy
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutAction
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutEntry
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutError
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutIntent
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutObservation
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutPlan
+ Seihou.Core.Checkout: instance GHC.Internal.Show.Show Seihou.Core.Checkout.CheckoutPolicy
+ Seihou.Core.Checkout: mergeCheckoutRecord :: FilePath -> CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Either CheckoutError (Map FilePath CheckoutOutputRecord)
+ Seihou.Core.Checkout: newtype CheckoutPlan
+ Seihou.Core.Checkout: pathsOverlap :: FilePath -> FilePath -> Bool
+ Seihou.Core.Checkout: planCheckoutOutputs :: CheckoutIntent -> CheckoutPolicy -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutObservation -> CheckoutPlan
+ Seihou.Core.Checkout: replaceCheckoutApplications :: Manifest -> [(AppliedComposition, [Operation])] -> Either CheckoutError Manifest
+ Seihou.Core.Checkout: validateCheckoutPaths :: FilePath -> FilePath -> Either CheckoutError ()
+ Seihou.Core.Checkout: validateCheckoutRecords :: Manifest -> [FilePath] -> Either CheckoutError ()
+ Seihou.Core.Module: checkCheckoutSteps :: Module -> [Text]
+ Seihou.Core.Module: checkSeedSteps :: Module -> [Text]
+ Seihou.Core.Path: validateCanonicalProjectPath :: Text -> Either Text ()
+ Seihou.Core.Seed: PathBothManagedAndSeeded :: FilePath -> ManifestInvariantError
+ Seihou.Core.Seed: data ManifestInvariantError
+ Seihou.Core.Seed: dropSeedOwner :: Set ApplicationId -> FilePath -> Manifest -> Manifest
+ Seihou.Core.Seed: instance GHC.Classes.Eq Seihou.Core.Seed.ManifestInvariantError
+ Seihou.Core.Seed: instance GHC.Internal.Generics.Generic Seihou.Core.Seed.ManifestInvariantError
+ Seihou.Core.Seed: instance GHC.Internal.Show.Show Seihou.Core.Seed.ManifestInvariantError
+ Seihou.Core.Seed: mergeSeedRecord :: FilePath -> SeedRecord -> Manifest -> Manifest
+ Seihou.Core.Seed: seedsOwnedByModule :: ModuleName -> Manifest -> Map FilePath SeedRecord
+ Seihou.Core.Seed: validateManifestInvariants :: Manifest -> Either ManifestInvariantError ()
+ Seihou.Core.Types: Checkout :: StepLifecycle
+ Seihou.Core.Types: CheckoutLocalOutputs :: ManifestCapability
+ Seihou.Core.Types: CheckoutOutputOp :: !FilePath -> !CheckoutOutputSpec -> !ModuleName -> Operation
+ Seihou.Core.Types: CheckoutOutputRecord :: !CheckoutOutputSpec -> !Map ApplicationId (Set ModuleName) -> CheckoutOutputRecord
+ Seihou.Core.Types: DirectorySymlink :: FilePath -> CheckoutOutputSpec
+ Seihou.Core.Types: Managed :: StepLifecycle
+ Seihou.Core.Types: ManifestSchemaVersion :: Int -> ManifestSchemaVersion
+ Seihou.Core.Types: Seed :: StepLifecycle
+ Seihou.Core.Types: SeedCreated :: SeedOutcome
+ Seihou.Core.Types: SeedFoundExisting :: SeedOutcome
+ Seihou.Core.Types: SeedReceipts :: ManifestCapability
+ Seihou.Core.Types: SeedRecord :: !ModuleName -> !Set ApplicationId -> !SeedOutcome -> !UTCTime -> SeedRecord
+ Seihou.Core.Types: SeedReleasedFromManaged :: SeedOutcome
+ Seihou.Core.Types: SharedWriteAdditiveOnly :: SharedWriteMode
+ Seihou.Core.Types: SharedWriteRequiresOwnershipClosure :: SharedWriteMode
+ Seihou.Core.Types: SharedWriteUnknown :: SharedWriteMode
+ Seihou.Core.Types: Symlink :: Strategy
+ Seihou.Core.Types: TargetedAdditiveSharedPathUpdate :: ManifestCapability
+ Seihou.Core.Types: [checkoutOutputs] :: Manifest -> !Map FilePath CheckoutOutputRecord
+ Seihou.Core.Types: [lifecycle] :: Step -> !StepLifecycle
+ Seihou.Core.Types: [owners] :: CheckoutOutputRecord -> !Map ApplicationId (Set ModuleName)
+ Seihou.Core.Types: [seeds] :: Manifest -> !Map FilePath SeedRecord
+ Seihou.Core.Types: [settledAt] :: SeedRecord -> !UTCTime
+ Seihou.Core.Types: [sharedWriteMode] :: FileRecord -> !SharedWriteMode
+ Seihou.Core.Types: [spec] :: Operation -> !CheckoutOutputSpec
+ Seihou.Core.Types: [unManifestSchemaVersion] :: ManifestSchemaVersion -> Int
+ Seihou.Core.Types: data CheckoutOutputRecord
+ Seihou.Core.Types: data CheckoutOutputSpec
+ Seihou.Core.Types: data ManifestCapability
+ Seihou.Core.Types: data SeedOutcome
+ Seihou.Core.Types: data SeedRecord
+ Seihou.Core.Types: data SharedWriteMode
+ Seihou.Core.Types: data StepLifecycle
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.CheckoutOutputRecord
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.CheckoutOutputSpec
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.ManifestCapability
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.ManifestSchemaVersion
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.SeedOutcome
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.SeedRecord
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.SharedWriteMode
+ Seihou.Core.Types: instance GHC.Classes.Eq Seihou.Core.Types.StepLifecycle
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.CheckoutOutputSpec
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.ManifestCapability
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.ManifestSchemaVersion
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.SeedOutcome
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.SharedWriteMode
+ Seihou.Core.Types: instance GHC.Classes.Ord Seihou.Core.Types.StepLifecycle
+ Seihou.Core.Types: instance GHC.Internal.Enum.Bounded Seihou.Core.Types.ManifestCapability
+ Seihou.Core.Types: instance GHC.Internal.Enum.Bounded Seihou.Core.Types.SeedOutcome
+ Seihou.Core.Types: instance GHC.Internal.Enum.Bounded Seihou.Core.Types.SharedWriteMode
+ Seihou.Core.Types: instance GHC.Internal.Enum.Enum Seihou.Core.Types.ManifestCapability
+ Seihou.Core.Types: instance GHC.Internal.Enum.Enum Seihou.Core.Types.SeedOutcome
+ Seihou.Core.Types: instance GHC.Internal.Enum.Enum Seihou.Core.Types.SharedWriteMode
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.CheckoutOutputRecord
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.CheckoutOutputSpec
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.ManifestCapability
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.ManifestSchemaVersion
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.SeedOutcome
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.SeedRecord
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.SharedWriteMode
+ Seihou.Core.Types: instance GHC.Internal.Generics.Generic Seihou.Core.Types.StepLifecycle
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.CheckoutOutputRecord
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.CheckoutOutputSpec
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.ManifestCapability
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.ManifestSchemaVersion
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.SeedOutcome
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.SeedRecord
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.SharedWriteMode
+ Seihou.Core.Types: instance GHC.Internal.Show.Show Seihou.Core.Types.StepLifecycle
+ Seihou.Core.Types: knownSharedWriteMode :: Bool -> SharedWriteMode
+ Seihou.Core.Types: lifecycleToText :: StepLifecycle -> Text
+ Seihou.Core.Types: mergeSharedWriteMode :: Bool -> Maybe SharedWriteMode -> Bool -> SharedWriteMode
+ Seihou.Core.Types: newtype ManifestSchemaVersion
+ Seihou.Effect.Filesystem: EntryDirectory :: PathEntry
+ Seihou.Effect.Filesystem: EntryFile :: PathEntry
+ Seihou.Effect.Filesystem: EntryMissing :: PathEntry
+ Seihou.Effect.Filesystem: EntrySymlink :: FilePath -> PathEntry
+ Seihou.Effect.Filesystem: [CreateDirectoryLink] :: forall (a :: Type -> Type). FilePath -> FilePath -> Filesystem a ()
+ Seihou.Effect.Filesystem: [InspectPath] :: forall (a :: Type -> Type). FilePath -> Filesystem a PathEntry
+ Seihou.Effect.Filesystem: [NormalizePath] :: forall (a :: Type -> Type). FilePath -> Filesystem a [Char]
+ Seihou.Effect.Filesystem: [RemoveLink] :: forall (a :: Type -> Type). FilePath -> Filesystem a ()
+ Seihou.Effect.Filesystem: createLink :: forall (es :: [Effect]). Filesystem :> es => FilePath -> FilePath -> Eff es ()
+ Seihou.Effect.Filesystem: data PathEntry
+ Seihou.Effect.Filesystem: inspectPath :: forall (es :: [Effect]). Filesystem :> es => FilePath -> Eff es PathEntry
+ Seihou.Effect.Filesystem: instance GHC.Classes.Eq Seihou.Effect.Filesystem.PathEntry
+ Seihou.Effect.Filesystem: instance GHC.Internal.Generics.Generic Seihou.Effect.Filesystem.PathEntry
+ Seihou.Effect.Filesystem: instance GHC.Internal.Show.Show Seihou.Effect.Filesystem.PathEntry
+ Seihou.Effect.Filesystem: normalizePath :: forall (es :: [Effect]). Filesystem :> es => FilePath -> Eff es FilePath
+ Seihou.Effect.Filesystem: removeLink :: forall (es :: [Effect]). Filesystem :> es => FilePath -> Eff es ()
+ Seihou.Effect.FilesystemPure: [links] :: PureFS -> !Map FilePath FilePath
+ Seihou.Engine.Checkout: CheckoutExecution :: !Map FilePath Text -> ![CheckoutUndo] -> !Maybe CheckoutError -> CheckoutExecution
+ Seihou.Engine.Checkout: CheckoutUndo :: !FilePath -> !PathEntry -> !PathEntry -> ![FilePath] -> CheckoutUndo
+ Seihou.Engine.Checkout: [after] :: CheckoutUndo -> !PathEntry
+ Seihou.Engine.Checkout: [before] :: CheckoutUndo -> !PathEntry
+ Seihou.Engine.Checkout: [dest] :: CheckoutUndo -> !FilePath
+ Seihou.Engine.Checkout: [failure] :: CheckoutExecution -> !Maybe CheckoutError
+ Seihou.Engine.Checkout: [outcomes] :: CheckoutExecution -> !Map FilePath Text
+ Seihou.Engine.Checkout: [parents] :: CheckoutUndo -> ![FilePath]
+ Seihou.Engine.Checkout: [undo] :: CheckoutExecution -> ![CheckoutUndo]
+ Seihou.Engine.Checkout: applyCheckoutPlan :: forall (es :: [Effect]). (Filesystem :> es, Process :> es, IOE :> es) => CheckoutPolicy -> FilePath -> CheckoutPlan -> Eff es CheckoutExecution
+ Seihou.Engine.Checkout: applyCheckoutPlanWithHook :: forall (es :: [Effect]). (Filesystem :> es, Process :> es, IOE :> es) => (Int -> Eff es ()) -> CheckoutPolicy -> FilePath -> CheckoutPlan -> Eff es CheckoutExecution
+ Seihou.Engine.Checkout: checkoutGitRoot :: forall (es :: [Effect]). (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Eff es (Maybe FilePath)
+ Seihou.Engine.Checkout: data CheckoutExecution
+ Seihou.Engine.Checkout: data CheckoutUndo
+ Seihou.Engine.Checkout: instance GHC.Classes.Eq Seihou.Engine.Checkout.CheckoutExecution
+ Seihou.Engine.Checkout: instance GHC.Classes.Eq Seihou.Engine.Checkout.CheckoutUndo
+ Seihou.Engine.Checkout: instance GHC.Internal.Generics.Generic Seihou.Engine.Checkout.CheckoutExecution
+ Seihou.Engine.Checkout: instance GHC.Internal.Generics.Generic Seihou.Engine.Checkout.CheckoutUndo
+ Seihou.Engine.Checkout: instance GHC.Internal.Show.Show Seihou.Engine.Checkout.CheckoutExecution
+ Seihou.Engine.Checkout: instance GHC.Internal.Show.Show Seihou.Engine.Checkout.CheckoutUndo
+ Seihou.Engine.Checkout: observeCheckoutOutputs :: forall (es :: [Effect]). (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Eff es (Map FilePath CheckoutObservation)
+ Seihou.Engine.Checkout: prepareCheckoutApplication :: forall (es :: [Effect]). (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Manifest -> Manifest -> [Operation] -> Eff es (Either CheckoutError CheckoutPlan)
+ Seihou.Engine.Checkout: prepareCheckoutPlan :: forall (es :: [Effect]). (Filesystem :> es, Process :> es, IOE :> es) => CheckoutIntent -> CheckoutPolicy -> FilePath -> Manifest -> Manifest -> [FilePath] -> Eff es (Either CheckoutError CheckoutPlan)
+ Seihou.Engine.Checkout: rollbackCheckout :: forall (es :: [Effect]). (Filesystem :> es, IOE :> es) => FilePath -> [CheckoutUndo] -> Eff es (Map FilePath Text)
+ Seihou.Engine.Checkout: safeCheckoutManifest :: forall (es :: [Effect]). Filesystem :> es => FilePath -> Eff es (Either CheckoutError ())
+ Seihou.Engine.Preview: LinkPreview :: FilePath -> FilePath -> PreviewLine
+ Seihou.Engine.Reconcile: recordedSharedWriteMode :: Set ApplicationId -> DesiredFile -> Maybe FileRecord -> SharedWriteMode
+ Seihou.Engine.Remove: CheckoutRemovalSelection :: !ApplicationId -> !ModuleName -> !ModuleName -> !Maybe Removal -> !Manifest -> CheckoutRemovalSelection
+ Seihou.Engine.Remove: [applicationId] :: CheckoutRemovalSelection -> !ApplicationId
+ Seihou.Engine.Remove: [candidateManifest] :: CheckoutRemovalSelection -> !Manifest
+ Seihou.Engine.Remove: [instanceName] :: CheckoutRemovalSelection -> !ModuleName
+ Seihou.Engine.Remove: [moduleName] :: CheckoutRemovalSelection -> !ModuleName
+ Seihou.Engine.Remove: [removal] :: CheckoutRemovalSelection -> !Maybe Removal
+ Seihou.Engine.Remove: data CheckoutRemovalSelection
+ Seihou.Engine.Remove: instance GHC.Classes.Eq Seihou.Engine.Remove.CheckoutRemovalSelection
+ Seihou.Engine.Remove: instance GHC.Internal.Generics.Generic Seihou.Engine.Remove.CheckoutRemovalSelection
+ Seihou.Engine.Remove: instance GHC.Internal.Show.Show Seihou.Engine.Remove.CheckoutRemovalSelection
+ Seihou.Engine.Remove: selectCheckoutRemoval :: Manifest -> ModuleName -> Maybe ApplicationId -> Either CheckoutError (Maybe CheckoutRemovalSelection)
+ Seihou.Engine.Validate: checkSeedSteps :: Module -> [Text]
+ Seihou.Manifest.JSON: decodeManifestValue :: ByteString -> Either String Value
+ Seihou.Manifest.Types: instance Data.Aeson.Types.FromJSON.FromJSON Seihou.Core.Types.ManifestSchemaVersion
+ Seihou.Manifest.Types: instance Data.Aeson.Types.ToJSON.ToJSON Seihou.Core.Types.ManifestSchemaVersion
+ Seihou.Manifest.Types: manifestEncodingLoss :: Manifest -> [Text]
+ Seihou.Manifest.Types: manifestSupports :: ManifestCapability -> Manifest -> Bool
+ Seihou.Manifest.Types: minimumManifestVersion :: ManifestCapability -> ManifestSchemaVersion
+ Seihou.Manifest.Types: oldestDecodableManifestVersion :: ManifestSchemaVersion
+ Seihou.Manifest.Types: sharedWriteModeFromText :: Text -> Maybe SharedWriteMode
+ Seihou.Manifest.Types: sharedWriteModeToText :: SharedWriteMode -> Text
+ Seihou.Manifest.Upgrade: AddEmptyCheckoutOutputs :: UpgradeStepAction
+ Seihou.Manifest.Upgrade: AddEmptySeedReceipts :: UpgradeStepAction
+ Seihou.Manifest.Upgrade: ConvertMachineLocalPaths :: UpgradeStepAction
+ Seihou.Manifest.Upgrade: InferenceBearingUpgrade :: UpgradeStepKind
+ Seihou.Manifest.Upgrade: LosslessUpgrade :: UpgradeStepKind
+ Seihou.Manifest.Upgrade: MakeSharedWriteEvidenceExplicit :: UpgradeStepAction
+ Seihou.Manifest.Upgrade: MalformedDocument :: ManifestSchemaVersion -> Text -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: ManifestUpgradeStep :: !ManifestSchemaVersion -> !ManifestSchemaVersion -> !UpgradeStepAction -> !Text -> ManifestUpgradeStep
+ Seihou.Manifest.Upgrade: MissingUpgradeStep :: ManifestSchemaVersion -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: SourceNewerThanBinary :: ManifestSchemaVersion -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: SourceVersionUnsupported :: ManifestSchemaVersion -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: StampVersion :: UpgradeStepAction
+ Seihou.Manifest.Upgrade: StepRequiresInference :: ManifestUpgradeStep -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: StepVersionMismatch :: ManifestUpgradeStep -> ManifestSchemaVersion -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: TargetAboveCurrent :: ManifestSchemaVersion -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: TargetBelowSource :: ManifestSchemaVersion -> ManifestSchemaVersion -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: UnreadableSchemaVersion :: Text -> ManifestUpgradeError
+ Seihou.Manifest.Upgrade: [action] :: ManifestUpgradeStep -> !UpgradeStepAction
+ Seihou.Manifest.Upgrade: [fromVersion] :: ManifestUpgradeStep -> !ManifestSchemaVersion
+ Seihou.Manifest.Upgrade: [summary] :: ManifestUpgradeStep -> !Text
+ Seihou.Manifest.Upgrade: [toVersion] :: ManifestUpgradeStep -> !ManifestSchemaVersion
+ Seihou.Manifest.Upgrade: applyLosslessUpgradeStep :: ManifestUpgradeStep -> Value -> Either ManifestUpgradeError Value
+ Seihou.Manifest.Upgrade: data ManifestUpgradeError
+ Seihou.Manifest.Upgrade: data ManifestUpgradeStep
+ Seihou.Manifest.Upgrade: data UpgradeStepAction
+ Seihou.Manifest.Upgrade: data UpgradeStepKind
+ Seihou.Manifest.Upgrade: documentSchemaVersion :: Value -> Either ManifestUpgradeError ManifestSchemaVersion
+ Seihou.Manifest.Upgrade: instance GHC.Classes.Eq Seihou.Manifest.Upgrade.ManifestUpgradeError
+ Seihou.Manifest.Upgrade: instance GHC.Classes.Eq Seihou.Manifest.Upgrade.ManifestUpgradeStep
+ Seihou.Manifest.Upgrade: instance GHC.Classes.Eq Seihou.Manifest.Upgrade.UpgradeStepAction
+ Seihou.Manifest.Upgrade: instance GHC.Classes.Eq Seihou.Manifest.Upgrade.UpgradeStepKind
+ Seihou.Manifest.Upgrade: instance GHC.Classes.Ord Seihou.Manifest.Upgrade.UpgradeStepAction
+ Seihou.Manifest.Upgrade: instance GHC.Classes.Ord Seihou.Manifest.Upgrade.UpgradeStepKind
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Enum.Bounded Seihou.Manifest.Upgrade.UpgradeStepAction
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Enum.Bounded Seihou.Manifest.Upgrade.UpgradeStepKind
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Enum.Enum Seihou.Manifest.Upgrade.UpgradeStepAction
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Enum.Enum Seihou.Manifest.Upgrade.UpgradeStepKind
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Generics.Generic Seihou.Manifest.Upgrade.ManifestUpgradeError
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Generics.Generic Seihou.Manifest.Upgrade.ManifestUpgradeStep
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Generics.Generic Seihou.Manifest.Upgrade.UpgradeStepAction
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Generics.Generic Seihou.Manifest.Upgrade.UpgradeStepKind
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Show.Show Seihou.Manifest.Upgrade.ManifestUpgradeError
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Show.Show Seihou.Manifest.Upgrade.ManifestUpgradeStep
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Show.Show Seihou.Manifest.Upgrade.UpgradeStepAction
+ Seihou.Manifest.Upgrade: instance GHC.Internal.Show.Show Seihou.Manifest.Upgrade.UpgradeStepKind
+ Seihou.Manifest.Upgrade: makeSharedWriteEvidenceExplicit :: Value -> Either Text Value
+ Seihou.Manifest.Upgrade: manifestUpgradeSteps :: [ManifestUpgradeStep]
+ Seihou.Manifest.Upgrade: oldestUpgradableManifestVersion :: ManifestSchemaVersion
+ Seihou.Manifest.Upgrade: planManifestUpgrade :: ManifestSchemaVersion -> ManifestSchemaVersion -> Either ManifestUpgradeError [ManifestUpgradeStep]
+ Seihou.Manifest.Upgrade: planUpgradeWith :: [ManifestUpgradeStep] -> ManifestSchemaVersion -> ManifestSchemaVersion -> ManifestSchemaVersion -> Either ManifestUpgradeError [ManifestUpgradeStep]
+ Seihou.Manifest.Upgrade: renderManifestUpgradeError :: ManifestUpgradeError -> Text
+ Seihou.Manifest.Upgrade: setDocumentSchemaVersion :: ManifestSchemaVersion -> Value -> Value
+ Seihou.Manifest.Upgrade: upgradeDocumentLosslessly :: ManifestSchemaVersion -> Value -> Either ManifestUpgradeError ([ManifestUpgradeStep], Value)
+ Seihou.Manifest.Upgrade: upgradeStepKind :: UpgradeStepAction -> UpgradeStepKind
- Seihou.Core.Types: FileRecord :: !SHA256 -> !ModuleName -> !Strategy -> !UTCTime -> !Maybe BaselineRef -> !Set ApplicationId -> !Bool -> FileRecord
+ Seihou.Core.Types: FileRecord :: !SHA256 -> !ModuleName -> !Strategy -> !UTCTime -> !Maybe BaselineRef -> !Set ApplicationId -> !SharedWriteMode -> FileRecord
- Seihou.Core.Types: Manifest :: !Int -> !UTCTime -> ![AppliedModule] -> !Map VarName Text -> !Map FilePath FileRecord -> ![AppliedComposition] -> !Maybe AppliedRecipe -> !Maybe AppliedBlueprint -> ![AppliedBlueprintMigration] -> Manifest
+ Seihou.Core.Types: Manifest :: !ManifestSchemaVersion -> !UTCTime -> ![AppliedModule] -> !Map VarName Text -> !Map FilePath FileRecord -> !Map FilePath SeedRecord -> !Map FilePath CheckoutOutputRecord -> ![AppliedComposition] -> !Maybe AppliedRecipe -> !Maybe AppliedBlueprint -> ![AppliedBlueprintMigration] -> Manifest
- Seihou.Core.Types: Step :: !Strategy -> !FilePath -> !Text -> !Maybe Expr -> !Maybe PatchOp -> Step
+ Seihou.Core.Types: Step :: !Strategy -> !FilePath -> !Text -> !Maybe Expr -> !Maybe PatchOp -> !StepLifecycle -> Step
- Seihou.Core.Types: [version] :: Manifest -> !Int
+ Seihou.Core.Types: [version] :: Manifest -> !ManifestSchemaVersion
- Seihou.Effect.FilesystemPure: PureFS :: !Map FilePath Text -> !Set FilePath -> PureFS
+ Seihou.Effect.FilesystemPure: PureFS :: !Map FilePath Text -> !Set FilePath -> !Map FilePath FilePath -> PureFS
- Seihou.Manifest.Types: currentManifestVersion :: Int
+ Seihou.Manifest.Types: currentManifestVersion :: ManifestSchemaVersion

Files

seihou-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: seihou-core-version: 0.9.0.0+version: 0.10.0.0 synopsis: Core library for Seihou project scaffolding description:   Core library for Seihou, a composable project scaffolding system.@@ -44,6 +44,7 @@     Seihou.Core.ArtifactOriginDetect     Seihou.Core.ArtifactRef     Seihou.Core.Blueprint+    Seihou.Core.Checkout     Seihou.Core.CommandFingerprint     Seihou.Core.CommandVar     Seihou.Core.Context@@ -56,6 +57,7 @@     Seihou.Core.Registry     Seihou.Core.Scaffold     Seihou.Core.SchemaUpgrade+    Seihou.Core.Seed     Seihou.Core.Status     Seihou.Core.Types     Seihou.Core.Variable@@ -89,6 +91,7 @@     Seihou.Effect.ProcessInterp     Seihou.Effect.ProcessPure     Seihou.Engine.Baseline+    Seihou.Engine.Checkout     Seihou.Engine.Conflict     Seihou.Engine.DhallJSON     Seihou.Engine.Diff@@ -107,11 +110,13 @@     Seihou.Interaction.Confirm     Seihou.Interaction.Prompt     Seihou.Manifest.Hash+    Seihou.Manifest.JSON     Seihou.Manifest.Types+    Seihou.Manifest.Upgrade     Seihou.Prelude    build-depends:-    aeson >=2.1 && <3,+    aeson >=2.1.2 && <3,     aeson-pretty >=0.8 && <1,     base >=4.18 && <5,     base16-bytestring >=1.0 && <2,@@ -152,9 +157,11 @@     Seihou.Composition.ResolveSpec     Seihou.Core.AgentPromptSpec     Seihou.Core.ApplicationSpec+    Seihou.Core.ArtifactIdentitySpec     Seihou.Core.ArtifactOriginDetectSpec     Seihou.Core.ArtifactRefSpec     Seihou.Core.BlueprintSpec+    Seihou.Core.CheckoutSpec     Seihou.Core.CommandFingerprintSpec     Seihou.Core.CommandVarSpec     Seihou.Core.ContextSpec@@ -170,6 +177,7 @@     Seihou.Core.RegistrySyncSpec     Seihou.Core.ScaffoldSpec     Seihou.Core.SchemaUpgradeSpec+    Seihou.Core.SeedSpec     Seihou.Core.StatusSpec     Seihou.Core.TypesSpec     Seihou.Core.VariableSpec@@ -184,6 +192,7 @@     Seihou.Effect.LoggerSpec     Seihou.Effect.ManifestStoreSpec     Seihou.Engine.BaselineSpec+    Seihou.Engine.CheckoutSpec     Seihou.Engine.ConflictSpec     Seihou.Engine.DiffSpec     Seihou.Engine.ExecuteSpec@@ -208,9 +217,10 @@     Seihou.Interaction.ConfirmSpec     Seihou.Interaction.PromptSpec     Seihou.Manifest.TypesSpec+    Seihou.Manifest.UpgradeSpec    build-depends:-    aeson >=2.1 && <3,+    aeson >=2.1.2 && <3,     base >=4.18 && <5,     bytestring >=0.11 && <1,     containers >=0.6 && <1,@@ -221,6 +231,7 @@     generic-lens >=2.2 && <3,     hspec >=2.11 && <3,     lens >=5.2 && <6,+    process >=1.6 && <1.7,     seihou-core,     tasty >=1.4 && <2,     tasty-hspec >=1.2 && <2,
src/Seihou/Composition/Plan.hs view
@@ -18,6 +18,7 @@ import Data.Text.Lazy.Encoding qualified as TLE import Data.Yaml qualified as Yaml import Seihou.Composition.Instance (ModuleInstance, qualifiedName)+import Seihou.Core.Checkout (CheckoutError (..), checkoutClaims, pathsOverlap) import Seihou.Core.Types import Seihou.Engine.Plan (compilePlan) import Seihou.Engine.Section (applyTextPatch)@@ -40,7 +41,15 @@   results <- mapM compileOne modules   let (errs, opsPerModule) = partitionResults results   if null errs-    then pure (Right (mergeOperations opsPerModule))+    then do+      let ops = concatMap snd opsPerModule+          links = [dest | CheckoutOutputOp {dest} <- ops]+          bytes = [dest | op <- ops, Just dest <- [destOfOp op]]+          overlaps = ["checkout output '" <> T.pack link <> "' overlaps byte-writing output '" <> T.pack path <> "'" | link <- links, path <- bytes, pathsOverlap link path]+      pure $ case checkoutClaims (ApplicationId "composition") ops of+        Left err -> Left [err ^. #message]+        Right _ | not (null overlaps) -> Left overlaps+        Right _ -> Right (mergeOperations opsPerModule)     else pure (Left (concat errs))   where     compileOne (inst, m, dir, vars) = do@@ -83,6 +92,7 @@     go [] fileOwner _ opsAcc warningsAcc = (opsAcc, warningsAcc, fileOwner)     go ((name, op) : rest) fileOwner seenDirs opsAcc warningsAcc =       case op of+        CheckoutOutputOp {} -> go rest fileOwner seenDirs (op : opsAcc) warningsAcc         CreateDirOp p           | Set.member p seenDirs -> go rest fileOwner seenDirs opsAcc warningsAcc           | otherwise -> go rest fileOwner (Set.insert p seenDirs) (op : opsAcc) warningsAcc
src/Seihou/Core/Application.hs view
@@ -93,7 +93,8 @@ -- The current record's baseline is preserved: EP-65 captures the exact -- post-execution generated content before ownership is attached. ----- @additiveOnly@ can only be weakened here, never strengthened. This run+-- The shared-write mode can only be kept or weakened here, never+-- strengthened ('mergeSharedWriteMode'). This run -- executed only its own application's operations, so its answer covers only -- its own contributions; when a prior owner survives outside this run, the -- prior record is the only evidence about that owner's write mode, and a@@ -105,12 +106,15 @@ attachApplication applicationId previous current =   current     & #applicationIds .~ Set.insert applicationId (Set.union (current ^. #applicationIds) priorApplications)-    & #additiveOnly .~ ((current ^. #additiveOnly) && (not partial || priorAdditive))+    & #sharedWriteMode+      .~ mergeSharedWriteMode+        partial+        ((^. #sharedWriteMode) <$> previous)+        (current ^. #sharedWriteMode == SharedWriteAdditiveOnly)   where     priorApplications = maybe Set.empty (^. #applicationIds) previous     retainedOwners = Set.delete applicationId priorApplications     partial = not (Set.null retainedOwners)-    priorAdditive = maybe False (^. #additiveOnly) previous  varValueToText :: VarValue -> Text varValueToText (VText value) = value
src/Seihou/Core/ArtifactIdentity.hs view
@@ -20,9 +20,11 @@   ( sameArtifactIdentity,     normalizeOriginUrl,     normalizeProjectPath,+    isMachineLocalOriginUrl,   ) where +import Data.Char (isAsciiLower, isAsciiUpper) import Data.Maybe (fromMaybe) import Data.Text qualified as T import Seihou.Core.Types (ArtifactOrigin (..))@@ -75,3 +77,31 @@         then Just (drop (length prefix) path)         else Nothing     dropWhileEnd' p = reverse . dropWhile p . reverse++-- | Whether a recorded origin URL is a path on the machine that recorded it+-- rather than a location every developer can reach.+--+-- @seihou install@ accepts a local checkout, and before plan 98 it stored+-- that path verbatim as the installed copy's @sourceUrl@, which then reached+-- the manifest as a 'RemoteOrigin'. A path means nothing on another machine+-- (docs\/adr\/0001-manifest-is-a-checked-in-machine-independent-artifact.md)+-- and turns into a false origin mismatch on this one as soon as the artifact+-- is reinstalled from its real remote.+--+-- True for text that, once stripped, starts with @\/@, @.\/@, @..\/@, @~@+-- or @file:@, is exactly @.@ or @..@, or is a Windows drive path+-- (@C:\\@ or @C:\/@). False for URLs with a network scheme+-- (@https:\/\/@, @ssh:\/\/@, @git:\/\/@) and for scp-style+-- @user\@host:path@.+isMachineLocalOriginUrl :: Text -> Bool+isMachineLocalOriginUrl raw =+  any (`T.isPrefixOf` url) ["/", "./", "../", "~", "file:"]+    || url == "."+    || url == ".."+    || isDrivePath url+  where+    url = T.strip raw+    isDrivePath text = case T.unpack (T.take 3 text) of+      [letter, ':', sep] -> isDriveLetter letter && (sep == '\\' || sep == '/')+      _ -> False+    isDriveLetter c = isAsciiLower c || isAsciiUpper c
src/Seihou/Core/ArtifactOriginDetect.hs view
@@ -26,6 +26,7 @@ import Data.ByteString.Lazy qualified as LBS import Data.Generics.Labels () import Data.Text qualified as T+import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl) import Seihou.Core.Types (ArtifactOrigin (..)) import Seihou.Prelude import System.Directory (canonicalizePath, doesFileExist)@@ -69,11 +70,18 @@ --      'ProjectOrigin' holding the path relative to @projectRoot@ with --      forward slashes. --   2. Otherwise, if @artifactDir@ contains a readable---      @.seihou-origin.json@ with a @sourceUrl@, the result is a---      'RemoteOrigin' carrying that URL, the directory's base name, and---      the recorded @repoName@.+--      @.seihou-origin.json@ with a @sourceUrl@ that is not a machine-local+--      path, the result is a 'RemoteOrigin' carrying that URL, the+--      directory's base name, and the recorded @repoName@. --   3. Otherwise the result is a 'LocalOrigin' holding the directory's---      base name.+--      base name. This includes a @sourceUrl@ that+--      'isMachineLocalOriginUrl' recognises as a path (an install from a+--      local checkout whose commit seihou could not trace to a published+--      remote): the path is meaningful only on this machine, so recording it+--      would break the manifest for everyone else and turn into a false+--      origin mismatch here once the artifact is reinstalled from its real+--      remote. 'LocalOrigin' says what is actually known — the provenance+--      is not portable. detectArtifactOrigin :: FilePath -> FilePath -> IO ArtifactOrigin detectArtifactOrigin projectRoot artifactDir = do   root <- canonicalizeOr projectRoot@@ -84,8 +92,10 @@       originInfo <- readOriginInfo dir       let name = T.pack (takeFileName dir)       pure $ case originInfo of-        Just info -> RemoteOrigin (info ^. #sourceUrl) name (info ^. #repoName)-        Nothing -> LocalOrigin name+        Just info+          | not (isMachineLocalOriginUrl (info ^. #sourceUrl)) ->+              RemoteOrigin (info ^. #sourceUrl) name (info ^. #repoName)+        _ -> LocalOrigin name  -- | 'canonicalizePath' throws when an intermediate component does not -- exist, which happens in tests and for artifacts that were removed between
+ src/Seihou/Core/Checkout.hs view
@@ -0,0 +1,202 @@+module Seihou.Core.Checkout+  ( CheckoutError (..),+    checkoutError,+    validateCheckoutPaths,+    validateCheckoutRecords,+    mergeCheckoutRecord,+    dropCheckoutOwners,+    checkoutClaims,+    checkoutSource,+    replaceCheckoutApplications,+    CheckoutIntent (..),+    CheckoutPolicy (..),+    CheckoutObservation (..),+    CheckoutAction (..),+    CheckoutEntry (..),+    CheckoutPlan (..),+    planCheckoutOutputs,+    checkoutApplicable,+    expectedTarget,+    pathsOverlap,+  )+where++import Control.Monad (foldM, forM_, unless, when)+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Maybe (isJust, maybeToList)+import Data.Set qualified as Set+import Data.Text qualified as T+import Seihou.Composition.Instance (ModuleInstance (..), qualifiedName)+import Seihou.Core.Path (validateCanonicalProjectPath)+import Seihou.Core.Types+import Seihou.Effect.Filesystem (PathEntry (..))+import Seihou.Prelude+import System.FilePath.Posix qualified as P++-- | Stable machine code and an actionable human explanation.+data CheckoutError = CheckoutError {code :: !Text, message :: !Text}+  deriving stock (Eq, Show, Generic)++checkoutError :: Text -> Text -> CheckoutError+checkoutError = CheckoutError++checkoutSource :: CheckoutOutputSpec -> FilePath+checkoutSource (DirectorySymlink source) = source++pathsOverlap :: FilePath -> FilePath -> Bool+pathsOverlap a b = a == b || (a <> "/") `isPrefixOf` b || (b <> "/") `isPrefixOf` a+  where+    isPrefixOf x y = T.pack x `T.isPrefixOf` T.pack y++validateCheckoutPaths :: FilePath -> FilePath -> Either CheckoutError ()+validateCheckoutPaths source dest = do+  forM_ [source, dest] $ \path -> first (checkoutError "checkout_path_invalid") (validateCanonicalProjectPath (T.pack path))+  when (pathsOverlap source dest) $ Left (checkoutError "checkout_path_overlap" "source and destination must not overlap")++-- | Validate the entire candidate, including application-local instance ownership.+validateCheckoutRecords :: Manifest -> [FilePath] -> Either CheckoutError ()+validateCheckoutRecords manifest pending = do+  let outputs = manifest ^. #checkoutOutputs+      bytePaths = Map.keys (manifest ^. #files) <> Map.keys (manifest ^. #seeds) <> pending+  forM_ (Map.toAscList outputs) $ \(dest, record) -> do+    validateCheckoutPaths (checkoutSource (record ^. #spec)) dest+    when (Map.null (record ^. #owners)) $ bad dest "has no owners"+    forM_ (Map.toAscList (record ^. #owners)) $ \(appId, names) -> do+      when (Set.null names) $ bad dest "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 dest "names an instance outside its owning application"+        _ -> bad dest "names an unknown or duplicate application owner"+    forM_ bytePaths $ \path -> when (pathsOverlap dest path) $ bad dest ("overlaps managed or seed output '" <> T.pack path <> "'; resolve the lifecycle transition manually")+    forM_ (Map.keys outputs) $ \other -> do+      when (dest /= other && pathsOverlap dest other) $ bad dest ("overlaps checkout destination '" <> T.pack other <> "'")+      when (pathsOverlap (checkoutSource (record ^. #spec)) other) $ bad dest ("source overlaps checkout destination '" <> T.pack other <> "'")+  where+    bad dest reason = Left (checkoutError "checkout_declaration_invalid" ("checkout output '" <> T.pack dest <> "' " <> reason))++mergeCheckoutRecord :: FilePath -> CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Either CheckoutError (Map FilePath CheckoutOutputRecord)+mergeCheckoutRecord dest record records = case Map.lookup dest records of+  Nothing -> Right (Map.insert dest record records)+  Just old | old ^. #spec == record ^. #spec -> Right (Map.insert dest (old & #owners %~ Map.unionWith Set.union (record ^. #owners)) records)+  Just _ -> Left (checkoutError "checkout_owner_conflict" ("checkout owners disagree on '" <> T.pack dest <> "'"))++-- | Replace the selected claims; preserve all unselected application/instance owners.+dropCheckoutOwners :: Map ApplicationId (Set ModuleName) -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord+dropCheckoutOwners selected = Map.mapMaybe $ \record ->+  let retained = Map.mapMaybeWithKey (\app names -> let rest = names `Set.difference` Map.findWithDefault Set.empty app selected in if Set.null rest then Nothing else Just rest) (record ^. #owners)+   in if Map.null retained then Nothing else Just (record & #owners .~ retained)++checkoutClaims :: ApplicationId -> [Operation] -> Either CheckoutError (Map FilePath CheckoutOutputRecord)+checkoutClaims app = foldM add Map.empty+  where+    add records CheckoutOutputOp {dest, spec, moduleName} = mergeCheckoutRecord dest (CheckoutOutputRecord spec (Map.singleton app (Set.singleton moduleName))) records+    add records _ = Right records++data CheckoutIntent = Rebuild | Reconcile deriving stock (Eq, Show, Generic)++data CheckoutPolicy = SyncIgnoredOnly | ApplyDeclaration deriving stock (Eq, Show, Generic)++data CheckoutObservation = CheckoutObservation+  { entry :: !PathEntry,+    sourceDirectory :: !Bool,+    problems :: ![CheckoutError],+    gitAvailable :: !Bool,+    ignored :: !Bool+  }+  deriving stock (Eq, Show, Generic)++data CheckoutAction = CreateLink | ReplaceLink | RetireLink | UnchangedLink | AlreadyAbsent | PreserveModified | RefusedLink+  deriving stock (Eq, Ord, Show, Generic)++data CheckoutEntry = CheckoutEntry+  { dest :: !FilePath,+    previous :: !(Maybe CheckoutOutputSpec),+    candidate :: !(Maybe CheckoutOutputSpec),+    observation :: !CheckoutObservation,+    action :: !CheckoutAction,+    errors :: ![CheckoutError],+    warnings :: ![Text]+  }+  deriving stock (Eq, Show, Generic)++newtype CheckoutPlan = CheckoutPlan {entries :: [CheckoutEntry]}+  deriving stock (Eq, Show, Generic)++checkoutApplicable :: CheckoutPlan -> Bool+checkoutApplicable = all (null . (^. #errors)) . (^. #entries)++expectedTarget :: FilePath -> CheckoutOutputSpec -> FilePath+expectedTarget dest (DirectorySymlink source) =+  let parent = P.takeDirectory dest+      depth = if parent == "." then 0 else length (P.splitDirectories parent)+   in P.joinPath (replicate depth ".." <> [source])++planCheckoutOutputs :: CheckoutIntent -> CheckoutPolicy -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutObservation -> CheckoutPlan+planCheckoutOutputs intent policy prior candidate observations = CheckoutPlan (map plan (Set.toAscList (Map.keysSet prior <> Map.keysSet candidate)))+  where+    plan dest =+      let old = (^. #spec) <$> Map.lookup dest prior+          new = (^. #spec) <$> Map.lookup dest candidate+          obs = Map.findWithDefault (CheckoutObservation EntryMissing False [checkoutError "checkout_observation_missing" "output was not observed"] False False) dest observations+          unchanged = intent == Reconcile && old == new+          active = isJust new+          syntax = [err | spec <- maybeToList new <> maybeToList old, Left err <- [validateCheckoutPaths (checkoutSource spec) dest]]+          policyErrors =+            [checkoutError "checkout_git_required" "sync requires a Git working tree" | policy == SyncIgnoredOnly, not (obs ^. #gitAvailable)]+              <> [checkoutError "checkout_not_ignored" "destination must be ignored; add it to .gitignore before running seihou sync" | policy == SyncIgnoredOnly, active, not (obs ^. #ignored)]+          preconditions =+            if unchanged+              then []+              else+                obs ^. #problems+                  <> policyErrors+                  <> [checkoutError "checkout_source_missing" "source must be an existing real project directory; restore it before retrying" | active, not (obs ^. #sourceDirectory)]+          contract = [checkoutError "checkout_rebuild_contract" "rebuild requires identical prior and candidate declarations" | intent == Rebuild, prior /= candidate]+          (proposed, collisions) = classify dest old new (obs ^. #entry) unchanged+          errs = contract <> syntax <> preconditions <> collisions+          warnings =+            ["preserved a modified checkout entry; released its declaration" | proposed == PreserveModified]+              <> ["Git evidence unavailable; applying a declaration before git-init" | not unchanged, policy == ApplyDeclaration, not (obs ^. #gitAvailable)]+              <> ["checkout destination is not ignored; clones require ignore rules for seihou sync" | not unchanged, policy == ApplyDeclaration, obs ^. #gitAvailable, active, not (obs ^. #ignored)]+       in CheckoutEntry dest old new obs (if null errs then proposed else RefusedLink) errs warnings+    classify _ _ _ _ True = (UnchangedLink, [])+    classify dest old Nothing entry _ = case entry of+      EntryMissing -> (AlreadyAbsent, [])+      EntrySymlink target | Just spec <- old, target == expectedTarget dest spec -> (RetireLink, [])+      _ -> (PreserveModified, [])+    classify dest old (Just spec) entry _ = case entry of+      EntryMissing -> (CreateLink, [])+      EntrySymlink target+        | target == expectedTarget dest spec -> (UnchangedLink, [])+        | intent == Rebuild || maybe False ((== target) . expectedTarget dest) old -> (ReplaceLink, [])+        | otherwise -> (RefusedLink, [checkoutError "checkout_link_modified" "link target changed locally; move it aside or restore its recorded target"])+      _ -> (RefusedLink, [checkoutError "checkout_destination_occupied" "destination is a real file or directory; move it aside manually"])++-- | Re-evaluation replaces a selected application's complete declaration set,+-- including dependencies that disappeared or whose conditions became false.+replaceCheckoutApplications :: Manifest -> [(AppliedComposition, [Operation])] -> Either CheckoutError Manifest+replaceCheckoutApplications prior applications = do+  let selected = Set.fromList [app ^. #applicationId | (app, _) <- applications]+      departing = Map.unionsWith Set.union [Map.restrictKeys (record ^. #owners) selected | record <- Map.elems (prior ^. #checkoutOutputs)]+      retained = dropCheckoutOwners departing (prior ^. #checkoutOutputs)+      updateApps apps (app, _) = app : filter ((/= app ^. #applicationId) . (^. #applicationId)) apps+  outputs <- foldM add retained applications+  let candidate = prior & #checkoutOutputs .~ outputs & #applications .~ foldl updateApps (prior ^. #applications) applications+      pending =+        [ dest+        | (_, ops) <- applications,+          op <- ops,+          dest <- case op of+            WriteFileOp {dest} -> [dest]+            CopyFileOp {dest} -> [dest]+            PatchFileOp {dest} -> [dest]+            _ -> []+        ]+  validateCheckoutRecords candidate pending+  forM_ pending $ \path -> forM_ (Map.keys (prior ^. #checkoutOutputs)) $ \old ->+    when (pathsOverlap path old) $ Left (checkoutError "checkout_lifecycle_transition" ("output '" <> T.pack path <> "' overlaps a previous checkout declaration; remove that declaration and resolve the transition manually"))+  pure candidate+  where+    add records (app, ops) = do+      claims <- checkoutClaims (app ^. #applicationId) ops+      foldM (\acc (dest, record) -> mergeCheckoutRecord dest record acc) records (Map.toAscList claims)
src/Seihou/Core/Module.hs view
@@ -25,6 +25,8 @@     checkSafeDestinations,     checkDestVarRefs,     checkCommandSafety,+    checkSeedSteps,+    checkCheckoutSteps,     isValidModuleName,     extractPlaceholders,     validateProjectRelativePath,@@ -33,8 +35,10 @@  import Data.Generics.Labels () import Data.Map.Strict qualified as Map+import Data.Maybe (isJust, maybeToList) import Data.Set qualified as Set import Data.Text qualified as T+import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutPaths) import Seihou.Core.Path (validateProjectRelativePath) import Seihou.Core.Types import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalModuleFromFile, evalRecipeFromFile)@@ -177,6 +181,8 @@           <> checkSafeDestinations m           <> checkDestVarRefs m           <> checkCommandSafety m+          <> checkSeedSteps m+          <> checkCheckoutSteps m       allErrors = pureErrors <> fileErrors   pure $     if null allErrors@@ -235,13 +241,16 @@ checkFileExistence baseDir m =   concat     <$> mapM-      ( \s -> do-          let p = baseDir </> "files" </> (s ^. #src)-          exists <- doesFileExist p-          pure $-            if exists-              then []-              else ["step source file not found: " <> T.pack (s ^. #src)]+      ( \s ->+          if s ^. #strategy == Symlink+            then pure []+            else do+              let p = baseDir </> "files" </> (s ^. #src)+              exists <- doesFileExist p+              pure $+                if exists+                  then []+                  else ["step source file not found: " <> T.pack (s ^. #src)]       )       (m ^. #steps) @@ -563,3 +572,39 @@     briefLoadError (MissingSourceFile _ _) = "missing source file"     briefLoadError (CircularDependency _) = "circular dependency"     briefLoadError (RegistryEvalError _ _) = "registry eval failed"++-- | A seed creates an entire file and must never be subject to automatic removal.+checkSeedSteps :: Module -> [Text]+checkSeedSteps m = concatMap checkStep seedSteps <> mixed <> removals+  where+    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]+    mixed =+      [ "steps writing '" <> dest <> "' mix the seed and managed lifecycles; use one lifecycle per destination"+      | dest <- Set.toAscList (Set.fromList (map (^. #dest) seedSteps)),+        any (\s -> s ^. #dest == dest && s ^. #lifecycle == Managed) (m ^. #steps)+      ]+    removals =+      [ "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+      ]++-- | Directory links are declarations about the consuming project, not cached files.+checkCheckoutSteps :: Module -> [Text]+checkCheckoutSteps m = concatMap check (m ^. #steps)+  where+    check s+      | s ^. #strategy /= Symlink && s ^. #lifecycle /= Checkout = []+      | otherwise =+          [label s <> "requires strategy symlink and lifecycle checkout together" | s ^. #strategy /= Symlink || s ^. #lifecycle /= Checkout]+            <> [label s <> "cannot carry a patch" | isJust (s ^. #patch)]+            <> [label s <> err ^. #message | Left err <- [validateCheckoutPaths (s ^. #src) (T.unpack (s ^. #dest))]]+            <> [label s <> "source references undeclared variable: " <> name | name <- extractPlaceholders (T.pack (s ^. #src)), name `notElem` map (^. #name . #unVarName) (m ^. #vars)]+            <> [label s <> "has malformed placeholders" | path <- [T.pack (s ^. #src), s ^. #dest], T.count "{{" path /= T.count "}}" path]+    label s = "step writing '" <> s ^. #dest <> "' "
src/Seihou/Core/Path.hs view
@@ -1,5 +1,6 @@ module Seihou.Core.Path   ( validateProjectRelativePath,+    validateCanonicalProjectPath,   ) where @@ -27,3 +28,15 @@ pathSegments =   filter (not . T.null)     . T.split (\c -> c == '/' || c == '\\')++-- | Checkout declarations use a canonical, portable spelling and reserve metadata.+-- This deliberately strengthens rather than changes the legacy validator above.+validateCanonicalProjectPath :: Text -> Either Text ()+validateCanonicalProjectPath raw = do+  _ <- validateProjectRelativePath raw+  if T.null raw+    || raw /= T.strip raw+    || T.any (\c -> c == '\\' || c == '\0' || c == ':') raw+    || 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 ()
+ src/Seihou/Core/Seed.hs view
@@ -0,0 +1,39 @@+module Seihou.Core.Seed+  ( mergeSeedRecord,+    dropSeedOwner,+    seedsOwnedByModule,+    ManifestInvariantError (..),+    validateManifestInvariants,+  )+where++import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+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+  where+    merge new old = old & #applicationIds %~ Set.union (new ^. #applicationIds)++dropSeedOwner :: Set ApplicationId -> FilePath -> Manifest -> Manifest+dropSeedOwner departing path = #seeds %~ Map.update dropOwners path+  where+    dropOwners record =+      let retained = (record ^. #applicationIds) `Set.difference` departing+       in if Set.null retained then Nothing else Just (record & #applicationIds .~ retained)++seedsOwnedByModule :: ModuleName -> Manifest -> Map FilePath SeedRecord+seedsOwnedByModule name manifest = Map.filter ((== name) . (^. #moduleName)) (manifest ^. #seeds)++data ManifestInvariantError = PathBothManagedAndSeeded FilePath+  deriving stock (Eq, Show, Generic)++validateManifestInvariants :: Manifest -> Either ManifestInvariantError ()+validateManifestInvariants manifest = case Map.keys (Map.intersection (manifest ^. #files) (manifest ^. #seeds)) of+  path : _ -> Left (PathBothManagedAndSeeded path)+  [] -> Right ()
src/Seihou/Core/Types.hs view
@@ -9,6 +9,12 @@     Prompt (..),     Expr (..),     Strategy (..),+    StepLifecycle (..),+    CheckoutOutputSpec (..),+    CheckoutOutputRecord (..),+    lifecycleToText,+    SeedOutcome (..),+    SeedRecord (..),     PatchOp (..),     isAdditivePatchOp,     Step (..),@@ -37,6 +43,11 @@     isAdditiveOperation,     ModuleLoadError (..),     Manifest (..),+    ManifestSchemaVersion (..),+    ManifestCapability (..),+    SharedWriteMode (..),+    knownSharedWriteMode,+    mergeSharedWriteMode,     ApplicationId (..),     ArtifactOrigin (..),     AppliedTarget (..),@@ -74,6 +85,7 @@ import Control.Lens ((^.)) import Data.Generics.Labels () import Data.Map.Strict (Map)+import Data.Maybe (fromMaybe) import Data.Set (Set) import Data.String (IsString) import Data.Text (Text)@@ -162,6 +174,7 @@   | Template   | DhallText   | Structured+  | Symlink   deriving stock (Eq, Show, Generic)  -- | Patch operations for modifying existing files during composition.@@ -194,13 +207,23 @@ isAdditivePatchOp AppendFile = False isAdditivePatchOp PrependFile = False +-- | Ownership of an output, independent of its materialization strategy.+data StepLifecycle = Managed | Seed | Checkout+  deriving stock (Eq, Ord, Show, Generic)++lifecycleToText :: StepLifecycle -> Text+lifecycleToText Managed = "managed"+lifecycleToText Seed = "seed"+lifecycleToText Checkout = "checkout"+ -- | A generation step within a module. data Step = Step   { strategy :: !Strategy,     src :: !FilePath,     dest :: !Text,     condition :: !(Maybe Expr),-    patch :: !(Maybe PatchOp)+    patch :: !(Maybe PatchOp),+    lifecycle :: !StepLifecycle   }   deriving stock (Eq, Show, Generic) @@ -419,8 +442,13 @@  -- | Filesystem operations produced by the generation engine. data Operation-  = WriteFileOp+  = CheckoutOutputOp       { dest :: !FilePath,+        spec :: !CheckoutOutputSpec,+        moduleName :: !ModuleName+      }+  | WriteFileOp+      { dest :: !FilePath,         content :: !Text,         strategy :: !Strategy       }@@ -457,6 +485,7 @@ -- never ask. isAdditiveOperation :: Operation -> Bool isAdditiveOperation (PatchFileOp _ _ op' _ _) = isAdditivePatchOp op'+isAdditiveOperation CheckoutOutputOp {} = False isAdditiveOperation WriteFileOp {} = False isAdditiveOperation CopyFileOp {} = False isAdditiveOperation CreateDirOp {} = False@@ -521,11 +550,18 @@ -- | Tracks the state of generated files for incremental re-generation -- and conflict detection. Stored at @.seihou/manifest.json@. data Manifest = Manifest-  { version :: !Int,+  { -- | The schema the document was read as, or the schema a producer+    -- asserts it can fully express. Decoding an older supported schema+    -- keeps its version here so 'Seihou.Manifest.Types.manifestSupports'+    -- can tell what the file actually proves; the encoder emits the+    -- representation that belongs to this version.+    version :: !ManifestSchemaVersion,     genAt :: !UTCTime,     modules :: ![AppliedModule],     vars :: !(Map VarName Text),     files :: !(Map FilePath FileRecord),+    seeds :: !(Map FilePath SeedRecord),+    checkoutOutputs :: !(Map FilePath CheckoutOutputRecord),     applications :: ![AppliedComposition],     recipe :: !(Maybe AppliedRecipe),     blueprint :: !(Maybe AppliedBlueprint),@@ -533,6 +569,74 @@   }   deriving stock (Eq, Show, Generic) +-- | The numeric schema of @.seihou/manifest.json@. Every semantic change to+-- the serialized manifest advances it (see+-- docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md).+-- It is serialized as a bare integer.+newtype ManifestSchemaVersion = ManifestSchemaVersion {unManifestSchemaVersion :: Int}+  deriving stock (Eq, Ord, Show, Generic)++-- | A feature whose behaviour depends on facts only some manifest schemas+-- can express. Each capability names its minimum schema in exactly one+-- place, 'Seihou.Manifest.Types.minimumManifestVersion', so no command+-- carries its own numeric version comparison.+data ManifestCapability+  = -- | A targeted update may leave an unselected co-owner of a path out of+    -- the selection when the path is certified additive-only. Requires the+    -- explicit shared-write evidence introduced by schema 7.+    TargetedAdditiveSharedPathUpdate+  | SeedReceipts+  | CheckoutLocalOutputs+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | What the manifest knows about how the owners of a path write it.+--+-- Schema 6 recorded this as an optional Boolean whose absence meant both+-- "an owner rewrites the whole file" and "this manifest predates the+-- question". Schema 7 keeps the two apart, so the ownership gate can fail+-- closed on 'SharedWriteUnknown' while still knowing the evidence can be+-- established on demand.+data SharedWriteMode+  = -- | Nobody has established the answer yet. Every path decoded from a+    -- schema-6 record without @additiveOnly: true@ starts here.+    SharedWriteUnknown+  | -- | Every contribution to the path goes through an additive,+    -- non-overlapping patch ('isAdditivePatchOp'), so reconciling one+    -- owner cannot disturb another's bytes.+    SharedWriteAdditiveOnly+  | -- | At least one contribution rewrites or repositions the file, so every+    -- owner must be part of any update that touches the path.+    SharedWriteRequiresOwnershipClosure+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | The mode a plan with complete knowledge of every contribution to a path+-- records.+knownSharedWriteMode :: Bool -> SharedWriteMode+knownSharedWriteMode True = SharedWriteAdditiveOnly+knownSharedWriteMode False = SharedWriteRequiresOwnershipClosure++-- | Combine this run's answer for a path with what the manifest held.+--+-- A run that executed every current owner (@partial = False@) knows the+-- whole answer and records it. A run that left a prior owner out knows only+-- its own contributions: a non-additive contribution of its own settles the+-- path as closure-required, but an additive one says nothing about the+-- retained owners, so the prior mode stands. The prior mode can therefore+-- only be kept or weakened here, never strengthened, and an unknown prior+-- stays unknown rather than being guessed.+mergeSharedWriteMode ::+  -- | Did a prior owner survive outside this run?+  Bool ->+  -- | The prior record's mode, if the path had one.+  Maybe SharedWriteMode ->+  -- | Is every contribution this run made additive?+  Bool ->+  SharedWriteMode+mergeSharedWriteMode partial prior candidateAdditive+  | not candidateAdditive = SharedWriteRequiresOwnershipClosure+  | not partial = SharedWriteAdditiveOnly+  | otherwise = fromMaybe SharedWriteUnknown prior+ -- | Stable identity for one top-level module or recipe application. newtype ApplicationId = ApplicationId {unApplicationId :: Text}   deriving stock (Eq, Ord, Show, Generic)@@ -753,12 +857,10 @@     generatedAt :: !UTCTime,     baseline :: !(Maybe BaselineRef),     applicationIds :: !(Set ApplicationId),-    -- | Does /every/ contribution to this path go through an additive,-    -- non-overlapping patch ('isAdditivePatchOp')? When true, reconciling-    -- one owner provably cannot disturb another's bytes, so a targeted-    -- update need not name every owner. Absent from older manifests, where-    -- it decodes as 'False' and the closure keeps being enforced.-    additiveOnly :: !Bool+    -- | How the owners of this path write it. Only+    -- 'SharedWriteAdditiveOnly' lets a targeted update leave a co-owner out+    -- of its selection; the other two keep the ownership closure enforced.+    sharedWriteMode :: !SharedWriteMode   }   deriving stock (Eq, Show, Generic) @@ -867,5 +969,27 @@   { path :: !FilePath,     moduleName :: !ModuleName,     status :: !TrackedFileStatus+  }+  deriving stock (Eq, Show, Generic)++-- | A seed's first settlement, with no content tracking.+data SeedOutcome = SeedCreated | SeedFoundExisting | SeedReleasedFromManaged+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++data SeedRecord = SeedRecord+  { moduleName :: !ModuleName,+    applicationIds :: !(Set ApplicationId),+    outcome :: !SeedOutcome,+    settledAt :: !UTCTime+  }+  deriving stock (Eq, Show, Generic)++-- | A typed, portable specification reconstructed in each working copy.+data CheckoutOutputSpec = DirectorySymlink FilePath+  deriving stock (Eq, Ord, Show, Generic)++data CheckoutOutputRecord = CheckoutOutputRecord+  { spec :: !CheckoutOutputSpec,+    owners :: !(Map ApplicationId (Set ModuleName))   }   deriving stock (Eq, Show, Generic)
src/Seihou/Dhall/Eval.hs view
@@ -98,7 +98,7 @@       Success m -> do         -- Force lazy decoder thunks that may contain 'error' calls         mapM_ (\v -> evaluate (v ^. #type_)) (m ^. #vars)-        mapM_ (\s -> evaluate (s ^. #strategy) >> evaluate (s ^. #condition) >> mapM_ evaluate (s ^. #patch)) (m ^. #steps)+        mapM_ (\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_ (\p -> evaluate (p ^. #condition)) (m ^. #prompts)         pure m@@ -562,8 +562,9 @@       "template" -> Template       "dhall-text" -> DhallText       "structured" -> Structured+      "symlink" -> Symlink       -- Caught by 'try' in 'evalModuleFromFile'-      other -> error ("Unknown strategy \"" <> T.unpack other <> "\"; expected one of: copy, template, dhall-text, structured")+      other -> error ("Unknown strategy \"" <> T.unpack other <> "\"; expected one of: copy, template, dhall-text, structured, symlink")  -- | Decoder for VarDecl from a Dhall record. --@@ -676,23 +677,31 @@ -- The @patch@ field is an optional patch operation string. stepDecoder :: Decoder Step stepDecoder =-  record-    ( mkStep-        <$> field "strategy" strategyDecoder-        <*> field "src" string-        <*> field "dest" strictText-        <*> field "when" (maybe strictText)-        <*> field "patch" (maybe strictText)-    )+  withDefaults [("lifecycle", noneText)] $+    record+      ( mkStep+          <$> field "strategy" strategyDecoder+          <*> field "src" string+          <*> field "dest" strictText+          <*> field "when" (maybe strictText)+          <*> field "patch" (maybe strictText)+          <*> field "lifecycle" (maybe strictText)+      )   where-    mkStep strat src dest whenText patchText =+    mkStep strat src dest whenText patchText lifecycleText =       Step         { strategy = strat,           src = src,           dest = dest,           condition = parseWhen whenText,-          patch = fmap parsePatchOp patchText+          patch = fmap parsePatchOp patchText,+          lifecycle = parseLifecycle lifecycleText         }+    parseLifecycle Nothing = Managed+    parseLifecycle (Just "managed") = Managed+    parseLifecycle (Just "seed") = Seed+    parseLifecycle (Just "checkout") = Checkout+    parseLifecycle (Just other) = error ("Unknown step lifecycle " <> T.unpack other <> "; expected one of: managed, seed, checkout")     parsePatchOp "append-file" = AppendFile     parsePatchOp "prepend-file" = PrependFile     parsePatchOp "append-section" = AppendSection
src/Seihou/Effect/Filesystem.hs view
@@ -1,5 +1,10 @@ module Seihou.Effect.Filesystem   ( Filesystem (..),+    PathEntry (..),+    inspectPath,+    createLink,+    removeLink,+    normalizePath,     readFileText,     writeFileText,     copyFile,@@ -17,7 +22,15 @@  import Seihou.Prelude +-- | A filesystem entry, observed without following its final component.+data PathEntry = EntryMissing | EntryFile | EntryDirectory | EntrySymlink FilePath+  deriving stock (Eq, Show, Generic)+ data Filesystem :: Effect where+  InspectPath :: FilePath -> Filesystem m PathEntry+  CreateDirectoryLink :: FilePath -> FilePath -> Filesystem m ()+  RemoveLink :: FilePath -> Filesystem m ()+  NormalizePath :: FilePath -> Filesystem m FilePath   ReadFileText :: FilePath -> Filesystem m Text   WriteFileText :: FilePath -> Text -> Filesystem m ()   CopyFile :: FilePath -> FilePath -> Filesystem m ()@@ -73,3 +86,15 @@  removeDirectoryRecursive :: (Filesystem :> es) => FilePath -> Eff es () removeDirectoryRecursive path = send (RemoveDirectoryRecursive path)++inspectPath :: (Filesystem :> es) => FilePath -> Eff es PathEntry+inspectPath = send . InspectPath++createLink :: (Filesystem :> es) => FilePath -> FilePath -> Eff es ()+createLink target path = send (CreateDirectoryLink target path)++removeLink :: (Filesystem :> es) => FilePath -> Eff es ()+removeLink = send . RemoveLink++normalizePath :: (Filesystem :> es) => FilePath -> Eff es FilePath+normalizePath = send . NormalizePath
src/Seihou/Effect/FilesystemInterp.hs view
@@ -3,16 +3,31 @@   ) where +import Control.Exception (IOException, catch, throwIO) import Control.Monad (when) import Data.Text.IO qualified as TIO-import Seihou.Effect.Filesystem (Filesystem (..))+import Seihou.Effect.Filesystem (Filesystem (..), PathEntry (..)) import Seihou.Prelude import System.Directory qualified as Dir+import System.IO.Error (isDoesNotExistError)  -- | Real IO interpreter for the Filesystem effect. -- Delegates to System.Directory and Data.Text.IO. runFilesystem :: (IOE :> es) => Eff (Filesystem : es) a -> Eff es a runFilesystem = interpret $ \_ -> \case+  InspectPath path -> liftIO $ do+    linked <- Dir.pathIsSymbolicLink path `catch` \(err :: IOException) -> if isDoesNotExistError err then pure False else throwIO err+    if linked+      then EntrySymlink <$> Dir.getSymbolicLinkTarget path+      else do+        directory <- Dir.doesDirectoryExist path+        file <- Dir.doesFileExist path+        pure (if directory then EntryDirectory else if file then EntryFile else EntryMissing)+  CreateDirectoryLink target path -> liftIO (Dir.createDirectoryLink target path)+  RemoveLink path -> liftIO $ do+    linked <- Dir.pathIsSymbolicLink path+    if linked then Dir.removeDirectoryLink path else ioError (userError "refusing to unlink a regular entry")+  NormalizePath path -> liftIO (Dir.canonicalizePath path)   ReadFileText path -> liftIO (TIO.readFile path)   WriteFileText path content -> liftIO (TIO.writeFile path content)   CopyFile src dest -> liftIO (Dir.copyFile src dest)
src/Seihou/Effect/FilesystemPure.hs view
@@ -9,19 +9,20 @@ 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 (..))+import Seihou.Effect.Filesystem (Filesystem (..), PathEntry (..)) import Seihou.Prelude  -- | In-memory filesystem state for testing. data PureFS = PureFS   { files :: !(Map FilePath Text),-    dirs :: !(Set FilePath)+    dirs :: !(Set FilePath),+    links :: !(Map FilePath FilePath)   }   deriving stock (Eq, Generic, Show)  -- | An empty in-memory filesystem. emptyFS :: PureFS-emptyFS = PureFS Map.empty Set.empty+emptyFS = PureFS Map.empty Set.empty Map.empty  -- | Pure in-memory interpreter for the Filesystem effect. -- Useful for testing without touching the real filesystem.@@ -30,6 +31,21 @@   where     handler :: (State PureFS :> es') => EffectHandler Filesystem es'     handler _ = \case+      InspectPath path -> do+        fs <- get @PureFS+        pure $ case Map.lookup path (fs ^. #links) of+          Just target -> EntrySymlink target+          Nothing+            | Map.member path (fs ^. #files) -> EntryFile+            | Set.member path (fs ^. #dirs) -> EntryDirectory+            | otherwise -> EntryMissing+      CreateDirectoryLink target path -> do+        fs <- get @PureFS+        if Map.member path (fs ^. #links) || Map.member path (fs ^. #files) || Set.member path (fs ^. #dirs)+          then error ("runFilesystemPure: destination exists: " <> path)+          else modify @PureFS (#links %~ Map.insert path target)+      RemoveLink path -> modify @PureFS (#links %~ Map.delete path)+      NormalizePath path -> pure path       ReadFileText path -> do         fs <- get @PureFS         case Map.lookup path (fs ^. #files) of@@ -48,7 +64,7 @@         let prefix = if null path then "" else path <> "/"             filesInDir =               [ drop (length prefix) fp-              | fp <- Map.keys (fs ^. #files),+              | fp <- Map.keys (fs ^. #files) <> Map.keys (fs ^. #links),                 isDirectChild prefix fp               ]             dirsInDir =@@ -114,6 +130,8 @@         .~ renamedFiles         & #dirs         .~ renamedDirs+        & #links+        %~ Map.mapKeys (renameKey src dest)   where     renameKey s d k       | k == s = d@@ -129,6 +147,8 @@       keepDir d = d /= path && not (prefix `isPrefixOfPath` d)    in fs         & #files+        %~ Map.filterWithKey (\k _ -> keepFile k)+        & #links         %~ Map.filterWithKey (\k _ -> keepFile k)         & #dirs         %~ Set.filter keepDir
+ src/Seihou/Engine/Checkout.hs view
@@ -0,0 +1,239 @@+module Seihou.Engine.Checkout+  ( observeCheckoutOutputs,+    prepareCheckoutPlan,+    prepareCheckoutApplication,+    applyCheckoutPlan,+    applyCheckoutPlanWithHook,+    rollbackCheckout,+    CheckoutExecution (..),+    CheckoutUndo (..),+    checkoutGitRoot,+    safeCheckoutManifest,+  )+where++import Control.Exception (IOException)+import Control.Monad (foldM, forM, forM_, unless, when)+import Data.Generics.Labels ()+import Data.List (sortOn)+import Data.Map.Strict qualified as Map+import Data.Maybe (maybeToList)+import Data.Set qualified as Set+import Data.Text qualified as T+import Effectful.Exception qualified as E+import Seihou.Core.Checkout+import Seihou.Core.Types+import Seihou.Effect.Filesystem+import Seihou.Effect.Process+import Seihou.Prelude+import System.Exit (ExitCode (..))+import System.FilePath.Posix qualified as P++checkoutGitRoot :: (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Eff es (Maybe FilePath)+checkoutGitRoot root = do+  result <- E.try @IOException (runProcess "git" ["rev-parse", "--show-toplevel"] (Just root))+  case result of+    Right (ExitSuccess, path, _) -> Just <$> normalizePath (T.unpack (T.stripEnd path))+    _ -> pure Nothing++-- | Inspect metadata before opening it; a manifest must be a real file.+safeCheckoutManifest :: (Filesystem :> es) => FilePath -> Eff es (Either CheckoutError ())+safeCheckoutManifest root = do+  dir <- inspectPath (root </> ".seihou")+  file <- if dir == EntryDirectory then inspectPath (root </> ".seihou/manifest.json") else pure EntryMissing+  pure $+    if dir == EntryDirectory && file == EntryFile+      then Right ()+      else Left (checkoutError "checkout_manifest_unsafe" "require a real .seihou directory and manifest.json file; run seihou run to record declarations, or move symlinked metadata aside")++ancestors :: FilePath -> [FilePath]+ancestors path = reverse (go (P.takeDirectory path))+  where+    go "." = []; go "" = []; go p = p : go (P.takeDirectory p)++-- | Observe entries themselves, never the targets of destination symlinks.+observeCheckoutOutputs :: (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Map FilePath CheckoutOutputRecord -> Map FilePath CheckoutOutputRecord -> Eff es (Map FilePath CheckoutObservation)+observeCheckoutOutputs root prior candidate = do+  gitRoot <- checkoutGitRoot root+  indexed <- case gitRoot of+    Nothing -> pure (Right [])+    Just _ -> do+      (code, out, _) <- runProcess "git" ["ls-files", "--cached", "-z"] (Just root)+      pure $ if code == ExitSuccess then Right (map T.unpack (filter (not . T.null) (T.splitOn "\0" out))) else Left (checkoutError "checkout_git_unavailable" "cannot inspect Git's index")+  metadataAlias <- inspectPath (root </> ".GIT")+  let nativeOverlap a b = pathsOverlap a b || (metadataAlias /= EntryMissing && pathsOverlap (T.unpack (T.toCaseFold (T.pack a))) (T.unpack (T.toCaseFold (T.pack b))))+  pairs <- forM allPaths $ \dest -> do+    let active = Map.lookup dest candidate+        record = case active of Just r -> r; Nothing -> prior Map.! dest+        source = checkoutSource (record ^. #spec)+    result <- E.try @IOException $ do+      parentProblems <- (<>) <$> checkAncestors (ancestors dest) <*> (if active == Nothing then pure [] else checkAncestors (ancestors source))+      entry <- if null parentProblems then inspectPath (root </> dest) else pure EntryMissing+      sourceEntry <- if active == Nothing || not (null parentProblems) then pure EntryMissing else inspectPath (root </> source)+      sourceAlias <- if active == Nothing || not (null parentProblems) then pure [] else checkSpelling source+      destAlias <- if null parentProblems then checkSpelling dest else pure []+      (ignored, ignoreProblems) <-+        if gitRoot == Nothing+          then pure (False, [])+          else do+            (code, _, _) <- runProcess "git" ["check-ignore", "-q", "--", "./" <> T.pack dest] (Just root)+            pure $ case code of+              ExitSuccess -> (True, [])+              ExitFailure 1 -> (False, [])+              _ -> (False, [checkoutError "checkout_git_unavailable" "cannot inspect ignore rules; check Git configuration"])++      let trackedProblems = case indexed of+            Left err -> [err]+            Right paths -> [checkoutError "checkout_path_tracked" "destination overlaps a tracked path (including an indexed ancestor); change the declaration or untrack it explicitly" | any (nativeOverlap dest) paths]+          aliasClaims =+            [ checkoutError "checkout_native_alias" "checkout declarations have ambiguous case spellings; use distinct portable destinations"+            | any (\other -> other /= dest && T.toCaseFold (T.pack other) == T.toCaseFold (T.pack dest)) allPaths+            ]+      pure (CheckoutObservation entry (sourceEntry == EntryDirectory) (parentProblems <> trackedProblems <> ignoreProblems <> sourceAlias <> destAlias <> aliasClaims <> [checkoutError "checkout_source_not_directory" "source is not a real directory; move the object aside" | active /= Nothing, sourceEntry `notElem` [EntryMissing, EntryDirectory]]) (gitRoot /= Nothing) ignored)+    pure+      ( dest,+        case result of+          Right obs -> obs+          Left err -> CheckoutObservation EntryMissing False [checkoutError "checkout_path_inaccessible" ("cannot inspect '" <> T.pack dest <> "': " <> T.pack (show err))] (gitRoot /= Nothing) False+      )+  pure (Map.fromList pairs)+  where+    allPaths = Set.toAscList (Map.keysSet prior <> Map.keysSet candidate)+    checkAncestors [] = pure []+    checkAncestors (path : rest) =+      inspectPath (root </> path) >>= \case+        EntrySymlink _ -> pure [checkoutError "checkout_parent_symlink" ("ancestor '" <> T.pack path <> "' is a symlink; replace it with a real directory")]+        EntryFile -> pure [checkoutError "checkout_parent_not_directory" ("ancestor '" <> T.pack path <> "' is a file; move it aside")]+        EntryMissing -> pure []+        EntryDirectory -> checkAncestors rest+    -- Native path lookup may accept a different spelling on case-insensitive volumes.+    checkSpelling path = fmap concat $ forM (ancestors path <> [path]) $ \part -> do+      parent <- inspectPath (root </> P.takeDirectory part)+      if parent /= EntryDirectory+        then pure []+        else do+          names <- listDirectory (root </> P.takeDirectory part)+          exists <- inspectPath (root </> part)+          pure [checkoutError "checkout_native_alias" ("path '" <> T.pack part <> "' resolves through a different native spelling") | exists /= EntryMissing, P.takeFileName part `notElem` names]++prepareCheckoutPlan :: (Filesystem :> es, Process :> es, IOE :> es) => CheckoutIntent -> CheckoutPolicy -> FilePath -> Manifest -> Manifest -> [FilePath] -> Eff es (Either CheckoutError CheckoutPlan)+prepareCheckoutPlan intent policy root prior candidate pending = case validateCheckoutRecords candidate pending of+  Left err -> pure (Left err)+  Right () -> do+    observations <- observeCheckoutOutputs root (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs)+    pure (Right (planCheckoutOutputs intent policy (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs) observations))++-- | An undo entry describes only this attempt's own writes.+data CheckoutUndo = CheckoutUndo+  {dest :: !FilePath, before :: !PathEntry, after :: !PathEntry, parents :: ![FilePath]}+  deriving stock (Eq, Show, Generic)++data CheckoutExecution = CheckoutExecution+  {outcomes :: !(Map FilePath Text), undo :: ![CheckoutUndo], failure :: !(Maybe CheckoutError)}+  deriving stock (Eq, Show, Generic)++rollbackCheckout :: (Filesystem :> es, IOE :> es) => FilePath -> [CheckoutUndo] -> Eff es (Map FilePath Text)+rollbackCheckout root writes =+  Map.fromList+    <$> forM+      writes+      ( \write -> do+          result <- E.try @IOException $ do+            let path = root </> (write ^. #dest)+            parentsSafe <- and <$> mapM (\p -> (== EntryDirectory) <$> inspectPath (root </> p)) (ancestors (write ^. #dest))+            current <- if parentsSafe then inspectPath path else pure EntryFile+            if current /= write ^. #after+              then pure "rollback-conflicted"+              else do+                case current of EntrySymlink _ -> removeLink path; _ -> pure ()+                case write ^. #before of EntrySymlink target -> createLink target path; _ -> pure ()+                forM_ (reverse (write ^. #parents)) (\p -> inspectPath (root </> p) >>= \kind -> when (kind == EntryDirectory) (removeDirectoryIfEmpty (root </> p)))+                pure "rolled-back"+          pure (write ^. #dest, either (const "rollback-conflicted") id result)+      )++-- | Recheck immediately before mutation. Supports a single cooperating writer;+-- portable path checks do not protect against hostile concurrent substitution.+applyCheckoutPlan :: (Filesystem :> es, Process :> es, IOE :> es) => CheckoutPolicy -> FilePath -> CheckoutPlan -> Eff es CheckoutExecution+applyCheckoutPlan = applyCheckoutPlanWithHook (const (pure ()))++-- | Failure injection after each completed link mutation, for rollback contract tests.+applyCheckoutPlanWithHook :: (Filesystem :> es, Process :> es, IOE :> es) => (Int -> Eff es ()) -> CheckoutPolicy -> FilePath -> CheckoutPlan -> Eff es CheckoutExecution+applyCheckoutPlanWithHook afterMutation policy root plan+  | not (checkoutApplicable plan) = pure (CheckoutExecution Map.empty [] (Just (checkoutError "checkout_preflight_refused" "checkout preflight refused; no outputs were changed")))+  | otherwise = go Map.empty [] (plan ^. #entries)+  where+    go outcomes writes [] = pure (CheckoutExecution outcomes writes Nothing)+    go outcomes writes (entry : rest)+      | entry ^. #action `notElem` [CreateLink, ReplaceLink, RetireLink] = go (Map.insert (entry ^. #dest) "unchanged" outcomes) writes rest+      | otherwise = do+          let dest = entry ^. #dest+              toMap spec = Map.singleton dest (CheckoutOutputRecord spec Map.empty)+              old = maybe Map.empty toMap (entry ^. #previous)+              new = maybe Map.empty toMap (entry ^. #candidate)+              expected = maybe EntryMissing (EntrySymlink . expectedTarget dest) (entry ^. #candidate)+          result <- E.try @IOException $ do+            observations <- observeCheckoutOutputs root old new+            let observed = observations Map.! dest+                changed = observed ^. #entry /= entry ^. #observation . #entry+                errors =+                  observed ^. #problems+                    <> [checkoutError "checkout_source_missing" "source directory disappeared" | entry ^. #candidate /= Nothing, not (observed ^. #sourceDirectory)]+                    <> [checkoutError "checkout_not_ignored" "destination is no longer ignored" | policy == SyncIgnoredOnly, entry ^. #candidate /= Nothing, not (observed ^. #ignored)]+                    <> [checkoutError "checkout_git_required" "Git evidence disappeared" | policy == SyncIgnoredOnly, not (observed ^. #gitAvailable)]+            when (changed || not (null errors)) $ E.throwIO (userError ("checkout precondition changed for " <> dest))+            missingParents <- filterM (\p -> (== EntryMissing) <$> inspectPath (root </> p)) (ancestors dest)+            -- Record and undo even a failure after unlink or during parent creation.+            let write = CheckoutUndo dest (observed ^. #entry) expected missingParents+            mutate <- E.try @IOException $ do+              forM_ missingParents (createDirectoryIfMissing False . (root </>))+              case entry ^. #action of+                CreateLink -> createLink (linkText entry) (root </> dest)+                ReplaceLink -> do+                  removeLink (root </> dest)+                  createLink (linkText entry) (root </> dest) `E.onException` createLink (oldText observed) (root </> dest)+                RetireLink -> removeLink (root </> dest)+                _ -> pure ()+            case mutate of+              Left err -> do+                forM_ (reverse missingParents) (\p -> inspectPath (root </> p) >>= \kind -> when (kind == EntryDirectory) (removeDirectoryIfEmpty (root </> p)))+                E.throwIO err+              Right () -> pure write+          case result of+            Right write -> do+              hooked <- E.try @IOException (afterMutation (length writes + 1))+              case hooked of+                Right () -> go (Map.insert dest "applied" outcomes) (write : writes) rest+                Left err -> do+                  reverted <- rollbackCheckout root (write : writes)+                  pure (CheckoutExecution (Map.union reverted outcomes) [] (Just (checkoutError "checkout_apply_failed" (T.pack (show err)))))+            Left err -> do+              reverted <- rollbackCheckout root writes+              pure (CheckoutExecution (Map.insert dest "failed" (Map.union reverted outcomes)) [] (Just (checkoutError "checkout_apply_failed" ("cannot apply '" <> T.pack dest <> "': " <> T.pack (show err) <> "; check symlink permissions and retry"))))+    linkText entry = case entry ^. #candidate of Just spec -> expectedTarget (entry ^. #dest) spec; Nothing -> ""+    oldText observed = case observed ^. #entry of EntrySymlink target -> target; _ -> ""++filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]+filterM predicate = foldM (\acc x -> predicate x >>= \keep -> pure (if keep then acc <> [x] else acc)) []++-- | Before ordinary generation, a source directory may be scheduled to appear+-- through deterministic file output. Shell commands cannot satisfy this dependency.+prepareCheckoutApplication :: (Filesystem :> es, Process :> es, IOE :> es) => FilePath -> Manifest -> Manifest -> [Operation] -> Eff es (Either CheckoutError CheckoutPlan)+prepareCheckoutApplication root prior candidate operations = case validateCheckoutRecords candidate bytePaths of+  Left err -> pure (Left err)+  Right () -> do+    observed <- observeCheckoutOutputs root (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs)+    let allowGenerated dest obs = case Map.lookup dest (candidate ^. #checkoutOutputs) of+          Just record | any (\path -> T.pack (checkoutSource (record ^. #spec) <> "/") `T.isPrefixOf` T.pack path) bytePaths -> obs & #sourceDirectory .~ True+          _ -> obs+    pure (Right (planCheckoutOutputs Reconcile ApplyDeclaration (prior ^. #checkoutOutputs) (candidate ^. #checkoutOutputs) (Map.mapWithKey allowGenerated observed)))+  where+    bytePaths =+      [ dest+      | op <- operations,+        dest <- case op of+          WriteFileOp {dest} -> [dest]+          CopyFileOp {dest} -> [dest]+          PatchFileOp {dest} -> [dest]+          _ -> []+      ]
src/Seihou/Engine/Execute.hs view
@@ -22,10 +22,12 @@ -- map fall back to the default @moduleName'@ — this preserves single- -- module call-sites and test usage that do not build an ownership map. ----- Each record's @additiveOnly@ is folded across /every/ operation that+-- Each record's shared-write mode is folded across /every/ operation that -- targets the same destination, so a path a module both writes and patches--- records 'False'. Only a path reached exclusively through additive patches--- records 'True'.+-- records 'SharedWriteRequiresOwnershipClosure'. Only a path reached+-- exclusively through additive patches records 'SharedWriteAdditiveOnly'.+-- The answer covers this plan's operations only; callers that retain prior+-- owners merge it with 'mergeSharedWriteMode'. executePlan ::   (Filesystem :> es) =>   FilePath ->@@ -36,8 +38,8 @@   Eff es (Map FilePath FileRecord) executePlan targetDir ops ownerMap moduleName' now = do   let ownerFor dest = Map.findWithDefault moduleName' dest ownerMap-      additiveFor dest = Map.findWithDefault False dest additiveMap-  records <- mapM (executeOp targetDir ownerFor additiveFor now) ops+      modeFor dest = knownSharedWriteMode (Map.findWithDefault False dest additiveMap)+  records <- mapM (executeOp targetDir ownerFor modeFor now) ops   pure (Map.fromList [(k, v) | Just (k, v) <- records])   where     additiveMap =@@ -53,6 +55,7 @@ operationDestination (WriteFileOp dest _ _) = Just dest operationDestination (CopyFileOp _ dest) = Just dest operationDestination (PatchFileOp dest _ _ _ _) = Just dest+operationDestination CheckoutOutputOp {} = Nothing operationDestination CreateDirOp {} = Nothing operationDestination RunCommandOp {} = Nothing @@ -61,11 +64,12 @@   (Filesystem :> es) =>   FilePath ->   (FilePath -> ModuleName) ->-  (FilePath -> Bool) ->+  (FilePath -> SharedWriteMode) ->   UTCTime ->   Operation ->   Eff es (Maybe (FilePath, FileRecord))-executeOp targetDir ownerFor additiveFor now op = case op of+executeOp targetDir ownerFor modeFor now op = case op of+  CheckoutOutputOp {} -> pure Nothing   WriteFileOp dest content strat -> do     let fullPath = targetDir </> dest     writeFileText fullPath content@@ -77,7 +81,7 @@               generatedAt = now,               baseline = Nothing,               applicationIds = mempty,-              additiveOnly = additiveFor dest+              sharedWriteMode = modeFor dest             }     pure (Just (dest, record))   CreateDirOp path -> do@@ -96,7 +100,7 @@               generatedAt = now,               baseline = Nothing,               applicationIds = mempty,-              additiveOnly = additiveFor dest+              sharedWriteMode = modeFor dest             }     pure (Just (dest, record))   RunCommandOp {} -> do@@ -123,7 +127,7 @@                   generatedAt = now,                   baseline = Nothing,                   applicationIds = mempty,-                  additiveOnly = additiveFor dest+                  sharedWriteMode = modeFor dest                 }         pure (Just (dest, record)) @@ -136,6 +140,7 @@   where     formatOp :: Operation -> Text     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     formatOp (CopyFileOp src dest) = "  copy  " <> T.pack src <> " -> " <> T.pack dest     formatOp RunCommandOp {command = cmd} = "  run   " <> cmd
src/Seihou/Engine/Plan.hs view
@@ -20,7 +20,9 @@ import Dhall qualified import Dhall.Core qualified as DhallCore import Dhall.Src (Src)+import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutPaths) import Seihou.Core.Expr (evalExpr)+import Seihou.Core.Module (checkCheckoutSteps, checkSeedSteps) import Seihou.Core.Path (validateProjectRelativePath) import Seihou.Core.Types import Seihou.Engine.DhallJSON (dhallExprToJSON)@@ -38,7 +40,10 @@   IO (Either [Text] [Operation]) compilePlan baseDir modul vars = do   let modName = (modul ^. #name)-  results <- mapM (compileStep baseDir modName vars) (modul ^. #steps)+  results <-+    if null (checkSeedSteps modul <> checkCheckoutSteps modul)+      then mapM (compileStep baseDir modName vars) (modul ^. #steps)+      else pure [Left (checkSeedSteps modul <> checkCheckoutSteps modul)]   let (allErrors, allOps) = partitionResults results   if null allErrors     then case compileCommands modName vars (modul ^. #commands) of@@ -115,6 +120,12 @@         Template -> compileTemplateStep baseDir vars step         DhallText -> compileDhallTextStep baseDir vars step         Structured -> compileStructuredStep baseDir vars step+        Symlink -> pure $ case (renderCommand (T.pack (step ^. #src)) vars, renderCommand (step ^. #dest) vars) of+          (Right source, Right dest) -> case validateCheckoutPaths (T.unpack source) (T.unpack dest) of+            Left err -> Left [err ^. #message]+            Right () -> Right [CheckoutOutputOp (T.unpack dest) (DirectorySymlink (T.unpack source)) modName]+          (Left errors, _) -> Left (map formatPlaceholderError errors)+          (_, Left errors) -> Left (map formatPlaceholderError errors)  -- | Compile a Copy step: read the source file and write it to the destination. compileCopyStep ::
src/Seihou/Engine/Preview.hs view
@@ -32,6 +32,7 @@         annotation :: !Text,         module_ :: !(Maybe ModuleName)       }+  | LinkPreview FilePath FilePath   | DirPreview FilePath   | CommandPreview Text (Maybe ModuleName)   | OrphanPreview FilePath ModuleName@@ -72,6 +73,7 @@       annotation = strategyName strat,       module_ = Map.lookup dest ownerMap     }+opToPreview _ _ _ (CheckoutOutputOp dest (DirectorySymlink source) _) = LinkPreview dest source opToPreview _ _ _ (CreateDirOp path) = DirPreview path opToPreview mDiff ownerMap _ (CopyFileOp _ dest) =   FilePreview@@ -128,6 +130,7 @@ renderPlainLine _ other = renderNonFileLine other  renderNonFileLine :: PreviewLine -> Text+renderNonFileLine (LinkPreview dest source) = "    link   " <> T.pack dest <> " -> " <> T.pack source renderNonFileLine (DirPreview path) =   "    mkdir  " <> T.pack path renderNonFileLine (CommandPreview cmd mOwner) =@@ -155,6 +158,7 @@ strategyName Template = "template" strategyName DhallText = "dhall-text" strategyName Structured = "structured"+strategyName Symlink = "symlink"  -- | Format a complete plan view with header, variables, operations, and summary. formatPlanView :: [ModuleName] -> Map VarName VarValue -> [PreviewLine] -> DiffResult -> Text
src/Seihou/Engine/Reconcile.hs view
@@ -18,6 +18,7 @@     reconciliationSummary,     reconciliationMutationPaths,     unresolvedPaths,+    recordedSharedWriteMode,   ) where @@ -121,6 +122,25 @@   }   deriving stock (Eq, Generic, Show) +-- | The shared-write mode an update records for a desired path, given the+-- applications it selected and the path's prior record.+--+-- Owners outside the selection are retained, and when any survive this run+-- knows only its own contributions, so the prior mode can be kept or+-- weakened but never strengthened ('mergeSharedWriteMode'). Both the+-- manifest writer ('Seihou.Engine.UpdateTransaction') and the no-op checks+-- use this one function so they cannot disagree about whether a plan would+-- change the record.+recordedSharedWriteMode :: Set ApplicationId -> DesiredFile -> Maybe FileRecord -> SharedWriteMode+recordedSharedWriteMode selected desired prior =+  mergeSharedWriteMode partial ((^. #sharedWriteMode) <$> prior) (desired ^. #additiveOnly)+  where+    -- Subtracting the /selection/ rather than the desired set matters: a+    -- selected application that stopped writing the path must genuinely lose+    -- ownership, while an unselected one must keep it.+    retainedOwners = maybe Set.empty (^. #applicationIds) prior Set.\\ selected+    partial = not (Set.null retainedOwners)+ data ReconciliationError   = InvalidReconciliationPath FilePath Text   | MissingDesiredOwner FilePath@@ -289,7 +309,7 @@         Nothing -> Right ()         Just record           | Set.null ((record ^. #applicationIds) Set.\\ selected) -> Right ()-          | record ^. #additiveOnly && all isAdditiveOperation pathOperations -> Right ()+          | record ^. #sharedWriteMode == SharedWriteAdditiveOnly && all isAdditiveOperation pathOperations -> Right ()           | otherwise -> Left (SharedPathRequiresApplications path (record ^. #applicationIds))  validateManagedPath :: FilePath -> Either ReconciliationError ()
src/Seihou/Engine/Remove.hs view
@@ -5,6 +5,8 @@     RemovalOp (..),     RemovalFileStatus (..),     ExecutedRemovalPlan (..),+    CheckoutRemovalSelection (..),+    selectCheckoutRemoval,     computeRemovalPlan,     executeRemoval,     buildRemovalOps,@@ -16,11 +18,14 @@ import Data.Generics.Labels () import Data.List (nub, sortBy) import Data.Map.Strict qualified as Map+import Data.Maybe (listToMaybe, mapMaybe, maybeToList) import Data.Ord (Down (..)) import Data.Set (Set) 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.Checkout import Seihou.Core.Path (validateProjectRelativePath) import Seihou.Core.Types import Seihou.Effect.Filesystem (Filesystem, doesFileExist, readFileText, removeDirectoryIfEmpty, removeFile, writeFileText)@@ -109,7 +114,10 @@   case findApplied manifest modName of     Nothing -> pure (Left (ModuleNotApplied modName))     Just am-      | Nothing <- am ^. #removal -> pure (Left (ModuleNotRemovable modName))+      | Nothing <- am ^. #removal ->+          if any (\record -> any (Set.member modName) (Map.elems (record ^. #owners))) (Map.elems (manifest ^. #checkoutOutputs))+            then pure (Right (RemovalPlan modName []))+            else pure (Left (ModuleNotRemovable modName))       | otherwise -> do           let ownedFiles = moduleFiles manifest modName           classified <- mapM classifyForRemoval ownedFiles@@ -357,3 +365,56 @@     %~ Map.filter (\rec -> rec ^. #moduleName /= modName)     & #genAt     .~ now++-- | A single recorded application/instance selected for checkout cleanup.+data CheckoutRemovalSelection = CheckoutRemovalSelection+  { applicationId :: !ApplicationId,+    instanceName :: !ModuleName,+    moduleName :: !ModuleName,+    removal :: !(Maybe Removal),+    candidateManifest :: !Manifest+  }+  deriving stock (Eq, Show, Generic)++-- | 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+  []+    | selectedApplication /= Nothing -> Left (checkoutError "checkout_remove_selection" "no matching checkout-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)))+        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)]+    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]))+  where+    candidates =+      [ (app, state, qualified)+      | app <- manifest ^. #applications,+        maybe True (== app ^. #applicationId) selectedApplication,+        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))+      ]
src/Seihou/Engine/UpdateTransaction.hs view
@@ -15,6 +15,7 @@ import Control.Monad (foldM, forM, forM_, unless, when) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=)) import Data.Aeson qualified as Aeson+import Data.Aeson.Types qualified as Aeson import Data.ByteString.Lazy qualified as LBS import Data.Foldable (traverse_) import Data.Generics.Labels ()@@ -28,6 +29,7 @@ import Seihou.Core.Types import Seihou.Effect.BaselineStore (putBaseline) import Seihou.Effect.BaselineStoreInterp (runBaselineStore)+import Seihou.Effect.Filesystem (PathEntry (..), inspectPath) import Seihou.Effect.FilesystemInterp (runFilesystem) import Seihou.Engine.Reconcile import Seihou.Manifest.Hash (hashContent)@@ -59,7 +61,8 @@  data JournalEntry = JournalEntry   { targetPath :: !FilePath,-    backupFile :: !(Maybe FilePath)+    backupFile :: !(Maybe FilePath),+    linkTarget :: !(Maybe FilePath)   }   deriving stock (Eq, Generic, Show) @@ -76,12 +79,30 @@   toJSON entry =     Aeson.object       [ "path" .= (entry ^. #targetPath),-        "backup" .= (entry ^. #backupFile)+        "backup" .= (entry ^. #backupFile),+        "target" .= (entry ^. #linkTarget),+        "kind"+          .= ( case (entry ^. #backupFile, entry ^. #linkTarget) of+                 (_, Just _) -> "symlink" :: Text+                 (Just _, _) -> "file"+                 _ -> "absent"+             )       ] -instance FromJSON JournalEntry where-  parseJSON = Aeson.withObject "JournalEntry" $ \object ->-    JournalEntry <$> object .: "path" <*> object .:? "backup"+parseJournalEntry :: Int -> Aeson.Value -> Aeson.Parser JournalEntry+parseJournalEntry version = Aeson.withObject "JournalEntry" $ \object -> do+  path <- object .: "path"+  backup <- object .:? "backup"+  if version == 1+    then pure (JournalEntry path backup Nothing)+    else do+      kind <- object .: "kind" :: Aeson.Parser Text+      target <- object .:? "target"+      case (kind, backup, target) of+        ("absent", Nothing, Nothing) -> pure (JournalEntry path Nothing Nothing)+        ("file", Just _, Nothing) -> pure (JournalEntry path backup Nothing)+        ("symlink", Nothing, Just _) -> pure (JournalEntry path Nothing target)+        _ -> fail "unknown or malformed transaction entry kind"  instance ToJSON JournalMetadata where   toJSON metadata =@@ -96,11 +117,11 @@ instance FromJSON JournalMetadata where   parseJSON = Aeson.withObject "JournalMetadata" $ \object -> do     version <- object .: "version"-    unless (version == 1) (fail "unsupported transaction journal version")+    unless (version `elem` [1, 2]) (fail "unsupported transaction journal version")     JournalMetadata       <$> pure version       <*> object .: "createdAt"-      <*> object .: "entries"+      <*> (mapM (parseJournalEntry version) =<< object .: "entries")       <*> object .: "newDirectories"       <*> object .:? "expectedManifest" @@ -125,18 +146,21 @@     let fullPath = transaction ^. #projectRoot </> relativePath         backupName = show index <> ".txt"         backupPath = backupDirectory </> backupName-    exists <- Directory.doesFileExist fullPath-    if exists-      then do+    verifySafeParents (transaction ^. #projectRoot) relativePath+    entry <- runEff $ runFilesystem $ inspectPath fullPath+    case entry of+      EntrySymlink target -> pure (JournalEntry relativePath Nothing (Just target))+      EntryFile -> do         TIO.readFile fullPath >>= TIO.writeFile backupPath-        pure (JournalEntry relativePath (Just backupName))-      else pure (JournalEntry relativePath Nothing)+        pure (JournalEntry relativePath (Just backupName) Nothing)+      EntryMissing -> pure (JournalEntry relativePath Nothing Nothing)+      EntryDirectory -> ioError (userError ("transaction target is a directory: " <> relativePath))   missingDirectories <- missingParentDirectories (transaction ^. #projectRoot) safeTargets   now <- getCurrentTime   writeJournal     (transaction ^. #transactionDirectory)     JournalMetadata-      { journalVersion = 1,+      { journalVersion = 2,         createdAt = now,         entries = entries,         newDirectories = missingDirectories,@@ -255,9 +279,6 @@             -- while an unselected one must keep it.             retainedOwners = maybe Set.empty (^. #applicationIds) prior Set.\\ selected             partial = not (Set.null retainedOwners)-            -- The prior flag summarises every owner, including the ones absent-            -- from this run, so a partial update can only weaken it.-            priorAdditive = maybe False (^. #additiveOnly) prior             record =               FileRecord                 { hash = state ^. #recordedHash,@@ -273,7 +294,10 @@                   generatedAt = manifest ^. #genAt,                   baseline = Just baseline,                   applicationIds = (desired ^. #applicationIds) `Set.union` retainedOwners,-                  additiveOnly = (desired ^. #additiveOnly) && (not partial || priorAdditive)+                  -- The prior mode summarises every owner, including the ones+                  -- absent from this run, so a partial update can only keep or+                  -- weaken it.+                  sharedWriteMode = recordedSharedWriteMode selected desired prior                 }         pure (Map.insert path record files)       Nothing -> pure (applyOrphanManifestAction selected reconciliation files)@@ -318,7 +342,7 @@       generatedAt = record ^. #generatedAt,       baseline = record ^. #baseline,       applicationIds = owners,-      additiveOnly = record ^. #additiveOnly+      sharedWriteMode = record ^. #sharedWriteMode     }  replaceManifestFiles :: Manifest -> Map FilePath FileRecord -> Manifest@@ -332,6 +356,8 @@       applications = manifest ^. #applications,       recipe = manifest ^. #recipe,       blueprint = manifest ^. #blueprint,+      seeds = manifest ^. #seeds,+      checkoutOutputs = manifest ^. #checkoutOutputs,       blueprintMigrations = manifest ^. #blueprintMigrations     } @@ -411,6 +437,7 @@     Left err -> pure (Left err)     Right metadata -> do       result <- try @SomeException $ do+        forM_ (metadata ^. #entries) (verifySafeParents (transaction ^. #projectRoot) . (^. #targetPath))         forM_ (metadata ^. #entries) (restoreEntry transaction)         removeNewDirectories (transaction ^. #projectRoot) (metadata ^. #newDirectories)         cleanupDirectory (transaction ^. #transactionDirectory)@@ -418,23 +445,49 @@  restoreEntry :: UpdateTransaction -> JournalEntry -> IO () restoreEntry transaction entry = do-  let target = transaction ^. #projectRoot </> (entry ^. #targetPath)-  case entry ^. #backupFile of-    Nothing -> do-      exists <- Directory.doesFileExist target-      when exists (Directory.removeFile target)-    Just backupName -> do+  let root = transaction ^. #projectRoot+      target = root </> (entry ^. #targetPath)+  verifySafeParents root (entry ^. #targetPath)+  current <- runEff $ runFilesystem $ inspectPath target+  -- Never follow a current link while restoring a file snapshot or a link.+  case current of+    EntrySymlink _ -> Directory.removeDirectoryLink target+    EntryDirectory -> ioError (userError ("refusing to restore over directory: " <> target))+    _ -> pure ()+  case (entry ^. #linkTarget, entry ^. #backupFile) of+    (Just link, _) -> do+      when (current == EntryFile) (ioError (userError ("refusing to restore link over a regular file: " <> target)))+      Directory.createDirectoryIfMissing True (takeDirectory target)+      Directory.createDirectoryLink link target+    (_, Just backupName) -> do       let backupPath = transaction ^. #transactionDirectory </> "backups" </> backupName+      backupEntry <- runEff $ runFilesystem $ inspectPath backupPath+      unless (backupEntry == EntryFile) (ioError (userError "transaction backup is not a real file"))       content <- TIO.readFile backupPath       atomicWriteText target content+    _ -> when (current == EntryFile) (Directory.removeFile target) +-- | Recovery must not traverse a substituted parent. Check from the root down.+verifySafeParents :: FilePath -> FilePath -> IO ()+verifySafeParents root path = go (reverse (relativeParents path))+  where+    go [] = pure ()+    go (parent : rest) = do+      entry <- runEff $ runFilesystem $ inspectPath (root </> parent)+      case entry of+        EntrySymlink _ -> ioError (userError ("transaction parent is a symlink: " <> parent))+        EntryFile -> ioError (userError ("transaction parent is not a directory: " <> parent))+        EntryMissing -> pure ()+        EntryDirectory -> go rest+ removeNewDirectories :: FilePath -> [FilePath] -> IO () removeNewDirectories projectRoot = mapM_ removeIfEmpty . reverse . sortOn pathDepth   where     removeIfEmpty relativePath = do       let fullPath = projectRoot </> relativePath-      exists <- Directory.doesDirectoryExist fullPath-      when exists $ do+      verifySafeParents projectRoot relativePath+      entry <- runEff $ runFilesystem $ inspectPath fullPath+      when (entry == EntryDirectory) $ do         contents <- Directory.listDirectory fullPath         when (null contents) (Directory.removeDirectory fullPath) @@ -537,7 +590,7 @@ validateJournal :: JournalMetadata -> Either Text JournalMetadata validateJournal metadata = do   traverse_ (validateJournalEntry . (^. #targetPath)) (metadata ^. #entries)-  traverse_ validateBackupName [name | JournalEntry _ (Just name) <- metadata ^. #entries]+  traverse_ validateBackupName [name | JournalEntry _ (Just name) _ <- metadata ^. #entries]   traverse_ (first renderTransactionPathError . validateTransactionPath) (metadata ^. #newDirectories)   pure metadata   where
src/Seihou/Engine/Validate.hs view
@@ -3,6 +3,7 @@     DiagCheck (..),     ValidateReport (..),     buildReport,+    checkSeedSteps,     renderReportPlain,     reportHasErrors,   )@@ -16,7 +17,8 @@ import Data.Text.IO qualified as TIO import Seihou.Core.Expr (exprRefs, parseExpr) import Seihou.Core.Module-  ( checkCommandSafety,+  ( checkCheckoutSteps,+    checkCommandSafety,     checkDependencyNames,     checkDestVarRefs,     checkExportRefs,@@ -24,6 +26,7 @@     checkNameFormat,     checkPromptRefs,     checkSafeDestinations,+    checkSeedSteps,     checkUniqueVars,     checkVersionPresent,     extractPlaceholders,@@ -75,6 +78,8 @@           DiagCheck "Source file existence" DiagError fileErrors,           DiagCheck "Dependency names" DiagError (checkDependencyNames m),           DiagCheck "Safe step destinations" DiagError (checkSafeDestinations m),+          DiagCheck "Checkout steps" DiagError (checkCheckoutSteps m),+          DiagCheck "Seed steps" DiagError (checkSeedSteps m),           DiagCheck "Destination variable references" DiagError (checkDestVarRefs m),           DiagCheck "Command safety" DiagError (checkCommandSafety m)         ]@@ -212,7 +217,7 @@ lintDuplicateDestinations m =   let nonPatchDests = [s ^. #dest | s <- m ^. #steps, isNothing (s ^. #patch)]       dupes = findDuplicates Set.empty Set.empty nonPatchDests-   in map (\d -> "multiple steps write to '" <> d <> "'") dupes+   in map (\d -> "multiple steps write to '" <> d <> "'") [d | d <- dupes, Set.size (Set.fromList [s ^. #lifecycle | s <- m ^. #steps, s ^. #dest == d]) == 1]  findDuplicates :: Set.Set Text -> Set.Set Text -> [Text] -> [Text] findDuplicates _ _ [] = []
+ src/Seihou/Manifest/JSON.hs view
@@ -0,0 +1,30 @@+-- | The raw-byte boundary shared by manifest readers and explicit upgrades.+-- Check multiplicity before Aeson turns object members into a map.+module Seihou.Manifest.JSON (decodeManifestValue) where++import Data.Aeson qualified as Aeson+import Data.Aeson.Decoding.ByteString.Lazy (lbsToTokens)+import Data.Aeson.Decoding.Tokens+import Data.Aeson.Key qualified as Key+import Data.ByteString.Lazy qualified as LBS+import Data.Set qualified as Set++-- | Reject duplicate keys, including escaped-equivalent spellings, at any depth.+decodeManifestValue :: LBS.ByteString -> Either String Aeson.Value+decodeManifestValue bytes = check (lbsToTokens bytes) >> Aeson.eitherDecode bytes+  where+    check :: Tokens k String -> Either String k+    check (TkLit _ rest) = Right rest+    check (TkText _ rest) = Right rest+    check (TkNumber _ rest) = Right rest+    check (TkErr err) = Left err+    check (TkArrayOpen array) = checkArray array+    check (TkRecordOpen record) = checkRecord Set.empty record+    checkArray (TkItem value) = check value >>= checkArray+    checkArray (TkArrayEnd rest) = Right rest+    checkArray (TkArrayErr err) = Left err+    checkRecord seen (TkPair key value)+      | Set.member key seen = Left ("duplicate manifest JSON key: " <> show (Key.toText key))+      | otherwise = check value >>= checkRecord (Set.insert key seen)+    checkRecord _ (TkRecordEnd rest) = Right rest+    checkRecord _ (TkRecordErr err) = Left err
src/Seihou/Manifest/Types.hs view
@@ -1,6 +1,12 @@ module Seihou.Manifest.Types   ( emptyManifest,+    manifestEncodingLoss,     currentManifestVersion,+    oldestDecodableManifestVersion,+    minimumManifestVersion,+    manifestSupports,+    sharedWriteModeToText,+    sharedWriteModeFromText,     manifestToJSON,     manifestFromJSON,     writeAppliedBlueprint,@@ -10,8 +16,11 @@   ) where +import Control.Monad (unless) import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=)) import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KM import Data.Aeson.Types qualified as Aeson import Data.ByteString.Lazy qualified as LBS import Data.Generics.Labels ()@@ -21,8 +30,11 @@ import Data.Text qualified as T import Data.Time (UTCTime) import Seihou.Core.ArtifactIdentity (sameArtifactIdentity)+import Seihou.Core.Checkout (CheckoutError (..), validateCheckoutRecords)+import Seihou.Core.Seed (ManifestInvariantError (..), validateManifestInvariants) import Seihou.Core.Types import Seihou.Manifest.Hash (baselineRefFromText)+import Seihou.Manifest.JSON (decodeManifestValue) import Seihou.Prelude hiding ((.=)) import System.FilePath (takeFileName) @@ -53,16 +65,42 @@ -- Version-5-and-earlier manifests are not readable directly; see -- docs/plans/79-upgrade-legacy-absolute-path-manifests-in-place.md. ----- Deliberately /not/ bumped from 6 to 7 when 'FileRecord' gained--- @additiveOnly@. The field is emitted only when true, so a manifest with no--- additive-only paths is byte-identical to one written before it existed, and--- a reader that predates it treats every path as requiring the full ownership--- closure -- the conservative reading. Bumping would make every manifest this--- release writes unreadable to 0.8.x binaries in exchange for nothing. See--- docs/plans/90-exempt-additive-patch-paths-from-the-shared-ownership-closure.md.-currentManifestVersion :: Int-currentManifestVersion = 6+-- Bumped from 6 to 7 when every 'FileRecord' gained a required+-- @sharedWriteMode@ (see+-- docs/plans/92-define-manifest-schema-capabilities-and-ordered-upgrade-steps.md).+-- Schema 6 carried the same fact as an optional @additiveOnly@ Boolean that+-- was emitted only when true, so an absent key meant both "an owner rewrites+-- the whole file" and "this manifest predates the question". Schema 6 still+-- decodes directly: @additiveOnly: true@ becomes 'SharedWriteAdditiveOnly'+-- and anything else becomes 'SharedWriteUnknown'. The adjacent raw-document+-- step lives in "Seihou.Manifest.Upgrade"; the rule that every semantic+-- change advances this number is+-- docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md.+currentManifestVersion :: ManifestSchemaVersion+currentManifestVersion = ManifestSchemaVersion 9 +-- | The oldest schema the ordinary decoder reads. Anything older records+-- machine-specific absolute paths and must go through the explicit+-- @seihou manifest upgrade@ conversion first.+oldestDecodableManifestVersion :: ManifestSchemaVersion+oldestDecodableManifestVersion = ManifestSchemaVersion 6++-- | The oldest schema that can express the facts a capability relies on.+-- This is the only place a feature's manifest requirement is written down;+-- commands ask 'manifestSupports' instead of comparing version numbers.+minimumManifestVersion :: ManifestCapability -> ManifestSchemaVersion+minimumManifestVersion TargetedAdditiveSharedPathUpdate = ManifestSchemaVersion 7+minimumManifestVersion SeedReceipts = ManifestSchemaVersion 8+minimumManifestVersion CheckoutLocalOutputs = ManifestSchemaVersion 9++-- | Whether a manifest's schema can express everything a capability needs.+-- A decoded schema-6 manifest answers 'False' for+-- 'TargetedAdditiveSharedPathUpdate' even though its records carry an+-- explicit 'SharedWriteUnknown', because the file itself does not say so.+manifestSupports :: ManifestCapability -> Manifest -> Bool+manifestSupports capability manifest =+  manifest ^. #version >= minimumManifestVersion capability+ -- | Create an empty manifest with the given timestamp. emptyManifest :: UTCTime -> Manifest emptyManifest now =@@ -75,6 +113,8 @@       applications = [],       recipe = Nothing,       blueprint = Nothing,+      seeds = Map.empty,+      checkoutOutputs = Map.empty,       blueprintMigrations = []     } @@ -93,6 +133,8 @@       applications = m ^. #applications,       recipe = m ^. #recipe,       blueprint = Just ab,+      seeds = m ^. #seeds,+      checkoutOutputs = m ^. #checkoutOutputs,       blueprintMigrations = m ^. #blueprintMigrations     } @@ -115,6 +157,8 @@       applications = manifest ^. #applications,       recipe = manifest ^. #recipe,       blueprint = manifest ^. #blueprint,+      seeds = manifest ^. #seeds,+      checkoutOutputs = manifest ^. #checkoutOutputs,       blueprintMigrations = upsert (manifest ^. #blueprintMigrations)     }   where@@ -159,7 +203,7 @@  -- | Decode a manifest from JSON bytes. manifestFromJSON :: LBS.ByteString -> Either String Manifest-manifestFromJSON = Aeson.eitherDecode+manifestFromJSON bytes = decodeManifestValue bytes >>= Aeson.parseEither parseJSON  -- JSON instances @@ -170,50 +214,66 @@         "generatedAt" .= (m ^. #genAt),         "modules" .= (m ^. #modules),         "variables" .= varsToJSON (m ^. #vars),-        "files" .= filesToJSON (m ^. #files),+        "files" .= filesToJSON (m ^. #version) (m ^. #files),         "applications" .= (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]         ++ maybe [] (\r -> ["recipe" .= r]) (m ^. #recipe)         ++ maybe [] (\b -> ["blueprint" .= b]) (m ^. #blueprint) +-- | The version is read and checked before anything else, because the+-- file-record representation depends on it. instance FromJSON Manifest where   parseJSON = Aeson.withObject "Manifest" $ \o -> do     v <- o .: "version"     checkManifestVersion v-    Manifest-      <$> pure v-      <*> o .: "generatedAt"-      <*> o .: "modules"-      <*> (varsFromJSON =<< o .: "variables")-      <*> (filesFromJSON =<< o .: "files")-      <*> o Aeson..:? "applications" Aeson..!= []-      <*> o Aeson..:? "recipe"-      <*> o Aeson..:? "blueprint"-      <*> o Aeson..:? "blueprintMigrations" Aeson..!= []+    manifest <-+      Manifest+        <$> pure v+        <*> o .: "generatedAt"+        <*> 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 9 then Map.traverseWithKey (\_ -> parseCheckout) =<< o .: "checkoutOutputs" else pure Map.empty)+        <*> o Aeson..:? "applications" Aeson..!= []+        <*> 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")+      Right () -> case validateCheckoutRecords manifest [] of+        Left err -> fail (T.unpack (err ^. #message))+        Right () -> pure manifest  -- | Reject a manifest this build cannot read, naming the remedy. ----- Compatibility seam: schema-5-and-earlier manifests carry an absolute--- @source@ path in place of the portable @origin@. Decoding those is owned--- by docs/plans/79-upgrade-legacy-absolute-path-manifests-in-place.md; until--- that plan lands an older manifest fails here with a clear message rather--- than being silently misread. The @seihou manifest upgrade@ command the--- message names is delivered by that same plan, so the remedy does not exist--- yet.-checkManifestVersion :: Int -> Aeson.Parser ()+-- Schema-5-and-earlier manifests carry an absolute @source@ path in place of+-- the portable @origin@; converting them is an inference that stays behind+-- the explicit @seihou manifest upgrade@ command+-- (docs/adr/0005-legacy-manifests-convert-through-an-explicit-command.md),+-- so they fail here rather than being silently misread.+checkManifestVersion :: ManifestSchemaVersion -> Aeson.Parser () checkManifestVersion v   | v > currentManifestVersion =       fail "manifest was created by a newer version of seihou"-  | v < 6 =+  | v < oldestDecodableManifestVersion =       fail         ( "this manifest uses schema version "-            <> show v+            <> show (v ^. #unManifestSchemaVersion)             <> ", which records machine-specific absolute paths; run "             <> "'seihou manifest upgrade' to convert it"         )   | otherwise = pure () +instance ToJSON ManifestSchemaVersion where+  toJSON (ManifestSchemaVersion v) = toJSON v++instance FromJSON ManifestSchemaVersion where+  parseJSON value = ManifestSchemaVersion <$> parseJSON value+ instance ToJSON AppliedTarget where   toJSON (AppliedModuleTarget name) =     Aeson.object ["kind" .= ("module" :: Text), "name" .= (name ^. #unModuleName)]@@ -564,47 +624,77 @@     <*> o Aeson..:? "workDir"     <*> pure Nothing -instance ToJSON FileRecord where-  toJSON fr =-    Aeson.object $-      [ "hash" .= (fr ^. #hash . #unSHA256),-        "module" .= (fr ^. #moduleName . #unModuleName),-        "strategy" .= strategyToText (fr ^. #strategy),-        "generatedAt" .= (fr ^. #generatedAt)-      ]-        ++ maybe [] (\ref -> ["baseline" .= (ref ^. #unBaselineRef . #unSHA256)]) (fr ^. #baseline)-        ++ applicationIdsField (fr ^. #applicationIds)-        ++ additiveOnlyField (fr ^. #additiveOnly)-    where-      applicationIdsField ids-        | Set.null ids = []-        | otherwise = ["applications" .= map (^. #unApplicationId) (Set.toAscList ids)]-      -- Emitted only when true, so a manifest with no additive-only paths is-      -- byte-identical to one written before the field existed. A reader that-      -- predates the field ignores it and keeps enforcing the ownership-      -- closure everywhere, which is the conservative behaviour; that is why-      -- 'currentManifestVersion' does not move for this field.-      additiveOnlyField additive-        | additive = ["additiveOnly" .= True]-        | otherwise = []+-- | Encode a file record in the representation of the given schema.+--+-- Schema 7 always emits @sharedWriteMode@, including @unknown@, so absence+-- can never again stand for a value. Schema 6 can only say+-- @additiveOnly: true@; a closure-required path is written without the key+-- and reads back as unknown, which is the fail-closed direction.+fileRecordToJSON :: ManifestSchemaVersion -> FileRecord -> Aeson.Value+fileRecordToJSON schema fr =+  Aeson.object $+    [ "hash" .= (fr ^. #hash . #unSHA256),+      "module" .= (fr ^. #moduleName . #unModuleName),+      "strategy" .= strategyToText (fr ^. #strategy),+      "generatedAt" .= (fr ^. #generatedAt)+    ]+      ++ maybe [] (\ref -> ["baseline" .= (ref ^. #unBaselineRef . #unSHA256)]) (fr ^. #baseline)+      ++ applicationIdsField (fr ^. #applicationIds)+      ++ sharedWriteField (fr ^. #sharedWriteMode)+  where+    applicationIdsField ids+      | Set.null ids = []+      | otherwise = ["applications" .= map (^. #unApplicationId) (Set.toAscList ids)]+    sharedWriteField mode+      | schema >= ManifestSchemaVersion 7 = ["sharedWriteMode" .= sharedWriteModeToText mode]+      | mode == SharedWriteAdditiveOnly = ["additiveOnly" .= True]+      | otherwise = [] -instance FromJSON FileRecord where-  parseJSON = Aeson.withObject "FileRecord" $ \o -> do-    baselineText <- o Aeson..:? "baseline"-    baseline <- traverse parseBaselineRef baselineText-    FileRecord-      <$> (SHA256 <$> o .: "hash")-      <*> (ModuleName <$> o .: "module")-      <*> (strategyFromText =<< o .: "strategy")-      <*> o .: "generatedAt"-      <*> pure baseline-      <*> (Set.fromList . map ApplicationId <$> o Aeson..:? "applications" Aeson..!= [])-      <*> (o Aeson..:? "additiveOnly" Aeson..!= False)-    where-      parseBaselineRef value = case baselineRefFromText value of-        Just ref -> pure ref-        Nothing -> fail "baseline must be a 64-character hexadecimal SHA-256 digest"+-- | Decode a file record in the representation of the given schema.+parseFileRecord :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Parser FileRecord+parseFileRecord schema = Aeson.withObject "FileRecord" $ \o -> do+  baselineText <- o Aeson..:? "baseline"+  baseline <- traverse parseBaselineRef baselineText+  FileRecord+    <$> (SHA256 <$> o .: "hash")+    <*> (ModuleName <$> o .: "module")+    <*> (strategyFromText =<< o .: "strategy")+    <*> o .: "generatedAt"+    <*> pure baseline+    <*> (Set.fromList . map ApplicationId <$> o Aeson..:? "applications" Aeson..!= [])+    <*> parseSharedWrite o+  where+    parseBaselineRef value = case baselineRefFromText value of+      Just ref -> pure ref+      Nothing -> fail "baseline must be a 64-character hexadecimal SHA-256 digest"+    parseSharedWrite o+      | schema >= ManifestSchemaVersion 7 = do+          raw <- o Aeson..:? "sharedWriteMode"+          case raw of+            Nothing -> fail "missing required key \"sharedWriteMode\" (schema 7 records it on every file)"+            Just text -> case sharedWriteModeFromText text of+              Just mode -> pure mode+              Nothing ->+                fail+                  ( "invalid \"sharedWriteMode\" value "+                      <> show text+                      <> "; expected \"unknown\", \"additive-only\", or \"requires-ownership-closure\""+                  )+      | otherwise = do+          -- Version 6 omitted false, so only an explicit true proves anything.+          additive <- o Aeson..:? "additiveOnly" Aeson..!= False+          pure (if additive then SharedWriteAdditiveOnly else SharedWriteUnknown) +-- | The schema-7 wire spelling of a shared-write mode.+sharedWriteModeToText :: SharedWriteMode -> Text+sharedWriteModeToText SharedWriteUnknown = "unknown"+sharedWriteModeToText SharedWriteAdditiveOnly = "additive-only"+sharedWriteModeToText SharedWriteRequiresOwnershipClosure = "requires-ownership-closure"++sharedWriteModeFromText :: Text -> Maybe SharedWriteMode+sharedWriteModeFromText text =+  lookup text [(sharedWriteModeToText mode, mode) | mode <- [minBound .. maxBound]]+ instance ToJSON SHA256 where   toJSON (SHA256 t) = toJSON t @@ -634,13 +724,23 @@  -- Helpers for FilePath-keyed maps -filesToJSON :: Map FilePath FileRecord -> Aeson.Value-filesToJSON = toJSON . Map.mapKeys T.pack+filesToJSON :: ManifestSchemaVersion -> Map FilePath FileRecord -> Aeson.Value+filesToJSON schema = toJSON . Map.map (fileRecordToJSON schema) . Map.mapKeys T.pack -filesFromJSON :: Aeson.Value -> Aeson.Parser (Map FilePath FileRecord)-filesFromJSON v = do-  m <- parseJSON v :: Aeson.Parser (Map Text FileRecord)-  pure (Map.mapKeys T.unpack m)+-- | Each record is parsed under its path, so a malformed record's error+-- names the file it belongs to.+filesFromJSON :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Parser (Map FilePath FileRecord)+filesFromJSON schema v = do+  m <- parseJSON v :: Aeson.Parser (Map Text Aeson.Value)+  records <-+    Map.traverseWithKey+      ( \path value ->+          Aeson.modifyFailure+            (\err -> "file record " <> show path <> ": " <> err)+            (parseFileRecord schema value Aeson.<?> Aeson.Key (Key.fromText path))+      )+      m+  pure (Map.mapKeys T.unpack records)  -- Strategy serialization @@ -649,10 +749,70 @@ strategyToText Template = "template" strategyToText DhallText = "dhall-text" strategyToText Structured = "structured"+strategyToText Symlink = "symlink"  strategyFromText :: Text -> Aeson.Parser Strategy strategyFromText "copy" = pure Copy strategyFromText "template" = pure Template strategyFromText "dhall-text" = pure DhallText strategyFromText "structured" = pure Structured+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+    [ "module" .= (record ^. #moduleName . #unModuleName),+      "applications" .= map (^. #unApplicationId) (Set.toAscList (record ^. #applicationIds)),+      "outcome" .= seedOutcomeToText (record ^. #outcome),+      "settledAt" .= (record ^. #settledAt)+    ]++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+  outcome <-+    o .: "outcome" >>= \case+      "created" -> pure SeedCreated+      "found-existing" -> pure SeedFoundExisting+      "released-from-managed" -> pure SeedReleasedFromManaged+      (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 outcome+    <*> o .: "settledAt"++-- | 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))]+    <> ["seed receipts require manifest schema 8" | manifest ^. #version < ManifestSchemaVersion 8, not (Map.null (manifest ^. #seeds))]++checkoutToJSON :: CheckoutOutputRecord -> Aeson.Value+checkoutToJSON record =+  Aeson.object+    [ "spec" .= case record ^. #spec of+        DirectorySymlink source -> Aeson.object ["kind" .= ("directory-symlink" :: Text), "source" .= source],+      "owners" .= Map.mapKeys (^. #unApplicationId) (Map.map (map (^. #unModuleName) . Set.toAscList) (record ^. #owners))+    ]++parseCheckout :: Aeson.Value -> Aeson.Parser CheckoutOutputRecord+parseCheckout = Aeson.withObject "checkout output" $ \o -> do+  unless (Set.fromList (KM.keys o) == Set.fromList ["spec", "owners"]) $ fail "checkout output requires exactly spec and owners"+  spec <-+    o .: "spec"+      >>= Aeson.withObject+        "checkout spec"+        ( \fields -> do+            kind <- fields .: "kind" :: Aeson.Parser Text+            unless (kind == "directory-symlink") $ fail ("unknown checkout output kind: " <> T.unpack kind)+            unless (Set.fromList (KM.keys fields) == Set.fromList ["kind", "source"]) $ fail "directory-symlink requires exactly kind and source"+            DirectorySymlink <$> fields .: "source"+        )+  owners <- o .: "owners" :: Aeson.Parser (Map Text [Text])+  pure (CheckoutOutputRecord spec (Map.mapKeys ApplicationId (Map.map (Set.fromList . map ModuleName) owners)))
+ src/Seihou/Manifest/Upgrade.hs view
@@ -0,0 +1,282 @@+-- | The ordered, adjacent schema steps that take a raw+-- @.seihou/manifest.json@ document from any supported schema to a newer one.+--+-- Every semantic manifest change advances the schema and adds exactly one+-- @N -> N + 1@ step here+-- (docs/adr/0014-every-semantic-manifest-change-advances-the-schema-version.md).+-- A path is planned one adjacent step at a time, so a missing step is an+-- error rather than a silent jump, and a document's @version@ only ever+-- names the last step that actually ran.+--+-- Steps work on 'Aeson.Value' rather than 'Manifest' so that keys this build+-- does not model survive. Each step is classified: a lossless step is a+-- deterministic transform implemented here and may be staged by any command+-- that needs it; an inference-bearing step consults machine-local state and+-- is implemented only by the explicit @seihou manifest upgrade@ command in+-- @seihou-cli@ (docs/adr/0005-legacy-manifests-convert-through-an-explicit-command.md).+module Seihou.Manifest.Upgrade+  ( UpgradeStepAction (..),+    UpgradeStepKind (..),+    ManifestUpgradeStep (..),+    ManifestUpgradeError (..),+    oldestUpgradableManifestVersion,+    manifestUpgradeSteps,+    upgradeStepKind,+    planManifestUpgrade,+    planUpgradeWith,+    documentSchemaVersion,+    setDocumentSchemaVersion,+    applyLosslessUpgradeStep,+    upgradeDocumentLosslessly,+    makeSharedWriteEvidenceExplicit,+    renderManifestUpgradeError,+  )+where++import Control.Monad (foldM, unless)+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Generics.Labels ()+import Data.Text qualified as T+import Seihou.Core.Types+import Seihou.Manifest.Types (currentManifestVersion, sharedWriteModeToText)+import Seihou.Prelude++-- | What a step does to the document. The kind of each action is fixed by+-- 'upgradeStepKind', so a step cannot be declared lossless while doing+-- inference.+data UpgradeStepAction+  = -- | Only the version changes: every field the newer schema added decodes+    -- from its absence to the same default the older reader assumed.+    StampVersion+  | -- | Schema 5 to 6: machine-local @source@ and @targetSource@ paths+    -- become portable artifact origins. Implemented by the CLI.+    ConvertMachineLocalPaths+  | -- | Schema 6 to 7: the optional @additiveOnly@ Boolean becomes a+    -- required @sharedWriteMode@ on every file record.+    MakeSharedWriteEvidenceExplicit+  | AddEmptySeedReceipts+  | AddEmptyCheckoutOutputs+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | Whether a step may be staged automatically.+data UpgradeStepKind+  = -- | Deterministic and loses nothing; may be staged by a command that+    -- requires the newer schema and committed atomically with its result.+    LosslessUpgrade+  | -- | Consults machine-local state or chooses among plausible answers;+    -- only the explicit, reviewable @seihou manifest upgrade@ runs it.+    InferenceBearingUpgrade+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++-- | One adjacent schema step.+data ManifestUpgradeStep = ManifestUpgradeStep+  { fromVersion :: !ManifestSchemaVersion,+    toVersion :: !ManifestSchemaVersion,+    action :: !UpgradeStepAction,+    -- | A short description for upgrade reports.+    summary :: !Text+  }+  deriving stock (Eq, Show, Generic)++data ManifestUpgradeError+  = -- | The document has no integer @version@, or is not an object.+    UnreadableSchemaVersion Text+  | -- | Older than any schema this build knows how to upgrade.+    SourceVersionUnsupported ManifestSchemaVersion+  | -- | Written by a newer seihou than this one.+    SourceNewerThanBinary ManifestSchemaVersion+  | -- | Asked for a schema this build does not know.+    TargetAboveCurrent ManifestSchemaVersion+  | -- | Asked to go backwards: source, then requested target.+    TargetBelowSource ManifestSchemaVersion ManifestSchemaVersion+  | -- | No step starts at this version.+    MissingUpgradeStep ManifestSchemaVersion+  | -- | A lossless run reached a step only the explicit command may run.+    StepRequiresInference ManifestUpgradeStep+  | -- | A step was applied to a document at a different version.+    StepVersionMismatch ManifestUpgradeStep ManifestSchemaVersion+  | -- | The document did not have the shape its version promises.+    MalformedDocument ManifestSchemaVersion Text+  deriving stock (Eq, Show, Generic)++-- | The oldest schema any step starts from.+oldestUpgradableManifestVersion :: ManifestSchemaVersion+oldestUpgradableManifestVersion = ManifestSchemaVersion 1++-- | Every adjacent step, oldest first. Adding a schema version means adding+-- exactly one entry here; the test suite plans a path from every supported+-- version to 'currentManifestVersion', so a forgotten step fails it.+manifestUpgradeSteps :: [ManifestUpgradeStep]+manifestUpgradeSteps =+  [ step 1 StampVersion "per-instance parent variables",+    step 2 StampVersion "applied blueprint record",+    step 3 StampVersion "reproducible applications and generated baselines",+    step 4 StampVersion "blueprint migration receipts",+    step 5 ConvertMachineLocalPaths "portable artifact origins",+    step 6 MakeSharedWriteEvidenceExplicit "explicit shared-write evidence",+    step 7 AddEmptySeedReceipts "seed receipts",+    step 8 AddEmptyCheckoutOutputs "checkout outputs"+  ]+  where+    step from stepAction =+      ManifestUpgradeStep (ManifestSchemaVersion from) (ManifestSchemaVersion (from + 1)) stepAction++upgradeStepKind :: UpgradeStepAction -> UpgradeStepKind+upgradeStepKind StampVersion = LosslessUpgrade+upgradeStepKind ConvertMachineLocalPaths = InferenceBearingUpgrade+upgradeStepKind MakeSharedWriteEvidenceExplicit = LosslessUpgrade+upgradeStepKind AddEmptySeedReceipts = LosslessUpgrade+upgradeStepKind AddEmptyCheckoutOutputs = LosslessUpgrade++-- | The contiguous steps from @source@ to @target@. Planning from a version+-- to itself is an empty path.+planManifestUpgrade ::+  ManifestSchemaVersion ->+  ManifestSchemaVersion ->+  Either ManifestUpgradeError [ManifestUpgradeStep]+planManifestUpgrade = planUpgradeWith manifestUpgradeSteps currentManifestVersion++-- | 'planManifestUpgrade' over an explicit step table and current version,+-- so the gap check itself can be tested.+planUpgradeWith ::+  [ManifestUpgradeStep] ->+  ManifestSchemaVersion ->+  ManifestSchemaVersion ->+  ManifestSchemaVersion ->+  Either ManifestUpgradeError [ManifestUpgradeStep]+planUpgradeWith steps current source target+  | source > current = Left (SourceNewerThanBinary source)+  | source < oldestUpgradableManifestVersion = Left (SourceVersionUnsupported source)+  | target > current = Left (TargetAboveCurrent target)+  | target < source = Left (TargetBelowSource source target)+  | otherwise = walk source+  where+    walk version+      | version == target = Right []+      | otherwise = case [candidate | candidate <- steps, candidate ^. #fromVersion == version] of+          [found]+            | found ^. #toVersion == nextVersion version -> (found :) <$> walk (nextVersion version)+          _ -> Left (MissingUpgradeStep version)+    nextVersion (ManifestSchemaVersion v) = ManifestSchemaVersion (v + 1)++-- | Read a raw document's top-level @version@.+documentSchemaVersion :: Aeson.Value -> Either ManifestUpgradeError ManifestSchemaVersion+documentSchemaVersion (Aeson.Object fields) = case KeyMap.lookup "version" fields of+  Just value | Aeson.Success v <- Aeson.fromJSON value -> Right (ManifestSchemaVersion v)+  Just _ -> Left (UnreadableSchemaVersion "the \"version\" field is not an integer")+  Nothing -> Left (UnreadableSchemaVersion "the manifest has no \"version\" field")+documentSchemaVersion _ = Left (UnreadableSchemaVersion "the manifest is not a JSON object")++-- | Replace a raw document's top-level @version@, keeping every other key.+setDocumentSchemaVersion :: ManifestSchemaVersion -> Aeson.Value -> Aeson.Value+setDocumentSchemaVersion version (Aeson.Object fields) =+  Aeson.Object (KeyMap.insert "version" (Aeson.toJSON (version ^. #unManifestSchemaVersion)) fields)+setDocumentSchemaVersion _ other = other++-- | Run one lossless step. The document must be at the step's source+-- version; the version is advanced only after the transform succeeds.+applyLosslessUpgradeStep :: ManifestUpgradeStep -> Aeson.Value -> Either ManifestUpgradeError Aeson.Value+applyLosslessUpgradeStep upgradeStep document = do+  actual <- documentSchemaVersion document+  unless (actual == upgradeStep ^. #fromVersion) $+    Left (StepVersionMismatch upgradeStep actual)+  transformed <- case upgradeStep ^. #action of+    StampVersion -> Right document+    AddEmptyCheckoutOutputs -> case document of+      Aeson.Object fields -> case KeyMap.lookup "checkoutOutputs" fields of+        Nothing -> Right (Aeson.Object (KeyMap.insert "checkoutOutputs" (Aeson.object []) fields))+        Just (Aeson.Object _) -> Right document+        _ -> Left (MalformedDocument actual "checkoutOutputs must be an object")+      _ -> Left (MalformedDocument actual "manifest must be an object")+    AddEmptySeedReceipts -> case document of+      Aeson.Object fields -> case KeyMap.lookup "seeds" fields of+        Nothing -> Right (Aeson.Object (KeyMap.insert "seeds" (Aeson.object []) fields))+        Just (Aeson.Object _) -> Right document+        _ -> Left (MalformedDocument actual "seeds must be an object")+      _ -> Left (MalformedDocument actual "cannot add empty seeds: field already exists or document is not an object")+    MakeSharedWriteEvidenceExplicit ->+      first (MalformedDocument actual) (makeSharedWriteEvidenceExplicit document)+    ConvertMachineLocalPaths -> Left (StepRequiresInference upgradeStep)+  pure (setDocumentSchemaVersion (upgradeStep ^. #toVersion) transformed)++-- | Upgrade a raw document to @target@ using lossless steps only. Stops with+-- 'StepRequiresInference' before changing anything if the path crosses an+-- inference-bearing step. Returns the steps that ran with the result.+upgradeDocumentLosslessly ::+  ManifestSchemaVersion ->+  Aeson.Value ->+  Either ManifestUpgradeError ([ManifestUpgradeStep], Aeson.Value)+upgradeDocumentLosslessly target document = do+  source <- documentSchemaVersion document+  steps <- planManifestUpgrade source target+  case [s | s <- steps, upgradeStepKind (s ^. #action) == InferenceBearingUpgrade] of+    blocked : _ -> Left (StepRequiresInference blocked)+    [] -> (steps,) <$> foldM (flip applyLosslessUpgradeStep) document steps++-- | The schema 6 to 7 transform. Every file record gains a required+-- @sharedWriteMode@: an explicit @additiveOnly: true@ becomes+-- @additive-only@, and anything else becomes @unknown@, because schema 6+-- omitted @false@ and absence therefore proves nothing. The old key is+-- removed; every other member at every level is kept.+makeSharedWriteEvidenceExplicit :: Aeson.Value -> Either Text Aeson.Value+makeSharedWriteEvidenceExplicit (Aeson.Object fields) = case KeyMap.lookup "files" fields of+  Just (Aeson.Object records) -> do+    converted <- KeyMap.traverseWithKey convertRecord records+    Right (Aeson.Object (KeyMap.insert "files" (Aeson.Object converted) fields))+  Just _ -> Left "\"files\" is not an object"+  Nothing -> Left "the manifest has no \"files\" object"+  where+    convertRecord path (Aeson.Object record) = do+      mode <- case KeyMap.lookup "additiveOnly" record of+        Nothing -> Right SharedWriteUnknown+        Just (Aeson.Bool True) -> Right SharedWriteAdditiveOnly+        Just (Aeson.Bool False) -> Right SharedWriteUnknown+        Just _ -> Left ("file record " <> Key.toText path <> ": \"additiveOnly\" is not a Boolean")+      Right+        ( Aeson.Object+            ( KeyMap.insert "sharedWriteMode" (Aeson.String (sharedWriteModeToText mode)) $+                KeyMap.delete "additiveOnly" record+            )+        )+    convertRecord path _ = Left ("file record " <> Key.toText path <> " is not an object")+makeSharedWriteEvidenceExplicit _ = Left "the manifest is not a JSON object"++renderManifestUpgradeError :: ManifestUpgradeError -> Text+renderManifestUpgradeError err = case err of+  UnreadableSchemaVersion reason -> "cannot read the manifest schema version: " <> reason+  SourceVersionUnsupported v -> "manifest schema " <> showVersion v <> " is older than any schema seihou can upgrade"+  SourceNewerThanBinary v ->+    "manifest schema "+      <> showVersion v+      <> " was written by a newer seihou; this build understands up to schema "+      <> showVersion currentManifestVersion+  TargetAboveCurrent v ->+    "schema "+      <> showVersion v+      <> " is newer than this build supports (up to "+      <> showVersion currentManifestVersion+      <> ")"+  TargetBelowSource source target ->+    "cannot upgrade from schema " <> showVersion source <> " to the older schema " <> showVersion target+  MissingUpgradeStep v -> "no upgrade step is defined from schema " <> showVersion v+  StepRequiresInference s ->+    "upgrading from schema "+      <> showVersion (s ^. #fromVersion)+      <> " to "+      <> showVersion (s ^. #toVersion)+      <> " ("+      <> s ^. #summary+      <> ") needs review; run 'seihou manifest upgrade'"+  StepVersionMismatch s actual ->+    "the "+      <> showVersion (s ^. #fromVersion)+      <> " -> "+      <> showVersion (s ^. #toVersion)+      <> " step cannot run on a schema-"+      <> showVersion actual+      <> " document"+  MalformedDocument v reason -> "the schema-" <> showVersion v <> " manifest is malformed: " <> reason+  where+    showVersion (ManifestSchemaVersion v) = T.pack (show v)
test/Main.hs view
@@ -7,9 +7,11 @@ import Seihou.Composition.ResolveSpec qualified as ResolveSpec import Seihou.Core.AgentPromptSpec qualified as AgentPromptSpec import Seihou.Core.ApplicationSpec qualified as ApplicationSpec+import Seihou.Core.ArtifactIdentitySpec qualified as ArtifactIdentitySpec import Seihou.Core.ArtifactOriginDetectSpec qualified as ArtifactOriginDetectSpec import Seihou.Core.ArtifactRefSpec qualified as ArtifactRefSpec import Seihou.Core.BlueprintSpec qualified as BlueprintSpec+import Seihou.Core.CheckoutSpec qualified as CheckoutSpec import Seihou.Core.CommandFingerprintSpec qualified as CommandFingerprintSpec import Seihou.Core.CommandVarSpec qualified as CommandVarSpec import Seihou.Core.ContextSpec qualified as ContextSpec@@ -25,6 +27,7 @@ import Seihou.Core.RegistrySyncSpec qualified as RegistrySyncSpec import Seihou.Core.ScaffoldSpec qualified as ScaffoldSpec import Seihou.Core.SchemaUpgradeSpec qualified as SchemaUpgradeSpec+import Seihou.Core.SeedSpec qualified as SeedSpec import Seihou.Core.StatusSpec qualified as StatusSpec import Seihou.Core.TypesSpec qualified as TypesSpec import Seihou.Core.VariableSpec qualified as VariableSpec@@ -39,6 +42,7 @@ import Seihou.Effect.LoggerSpec qualified as LoggerSpec import Seihou.Effect.ManifestStoreSpec qualified as ManifestStoreSpec import Seihou.Engine.BaselineSpec qualified as BaselineSpec+import Seihou.Engine.CheckoutSpec qualified as CheckoutEngineSpec import Seihou.Engine.ConflictSpec qualified as ConflictSpec import Seihou.Engine.DiffSpec qualified as DiffSpec import Seihou.Engine.ExecuteSpec qualified as ExecuteSpec@@ -63,6 +67,7 @@ import Seihou.Interaction.ConfirmSpec qualified as ConfirmSpec import Seihou.Interaction.PromptSpec qualified as PromptSpec import Seihou.Manifest.TypesSpec qualified as ManifestTypesSpec+import Seihou.Manifest.UpgradeSpec qualified as ManifestUpgradeSpec import Test.Tasty  main :: IO ()@@ -74,6 +79,7 @@   resolveTests <- ResolveSpec.tests   agentPromptTests <- AgentPromptSpec.tests   applicationTests <- ApplicationSpec.tests+  artifactIdentityTests <- ArtifactIdentitySpec.tests   artifactOriginDetectTests <- ArtifactOriginDetectSpec.tests   artifactRefTests <- ArtifactRefSpec.tests   blueprintTests <- BlueprintSpec.tests@@ -92,6 +98,9 @@   registryEmitTests <- RegistryEmitSpec.tests   registrySyncTests <- RegistrySyncSpec.tests   scaffoldTests <- ScaffoldSpec.tests+  checkoutEngineTests <- CheckoutEngineSpec.tests+  checkoutTests <- CheckoutSpec.tests+  seedTests <- SeedSpec.tests   schemaUpgradeTests <- SchemaUpgradeSpec.tests   statusTests <- StatusSpec.tests   variableTests <- VariableSpec.tests@@ -128,6 +137,7 @@   integrationTests <- IntegrationSpec.tests   generationTests <- GenerationSpec.tests   manifestTypesTests <- ManifestTypesSpec.tests+  manifestUpgradeTests <- ManifestUpgradeSpec.tests   promptTests <- PromptSpec.tests   confirmTests <- ConfirmSpec.tests-  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, artifactOriginDetectTests, artifactRefTests, blueprintTests, commandFingerprintTests, commandVarTests, typesTests, contextTests, entailmentTests, exprTests, installTests, listTests, migrationTests, moduleTests, recipeTests, registryTests, registryEmitTests, registrySyncTests, scaffoldTests, schemaUpgradeTests, statusTests, variableTests, versionTests, templateTests, threeWayMergeTests, updateTransactionTests, planTests, previewTests, reconcileTests, sectionTests, validateTests, splitFlakeTests, dhallTextFlakeTests, typedDhallTextTests, conditionalTemplateTests, configTests, dhallEvalTests, migrationDecoderTests, configReaderTests, configWriterTests, baselineStoreTests, filesystemTests, loggerTests, manifestStoreTests, conflictTests, baselineTests, diffTests, executeTests, engineMigrateTests, removeTests, compositionTests, executionTests, integrationTests, generationTests, manifestTypesTests, promptTests, confirmTests])+  defaultMain (testGroup "seihou-core" [graphTests, instanceTests, compositionPlanTests, compositionRecipeTests, resolveTests, agentPromptTests, applicationTests, 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/Core/ApplicationSpec.hs view
@@ -1,6 +1,7 @@ module Seihou.Core.ApplicationSpec (tests) where  import Control.Lens ((&), (?~), (^.))+import Control.Monad (forM_) import Data.Generics.Labels () import Data.Map.Strict qualified as Map import Data.Set qualified as Set@@ -158,41 +159,50 @@     it "unions prior and current ownership and preserves the generated baseline" $ do       let priorId = ApplicationId "prior"           currentId = ApplicationId "current"-          prior = FileRecord (hashContent "old") "module" Template fixedTime Nothing (Set.singleton priorId) False-          current = FileRecord (hashContent "new") "module" Template fixedTime (Just (BaselineRef (hashContent "generated"))) Set.empty False+          prior = FileRecord (hashContent "old") "module" Template fixedTime Nothing (Set.singleton priorId) SharedWriteUnknown+          current = FileRecord (hashContent "new") "module" Template fixedTime (Just (BaselineRef (hashContent "generated"))) Set.empty SharedWriteRequiresOwnershipClosure           attached = attachApplication currentId (Just prior) current       (attached ^. #applicationIds) `shouldBe` Set.fromList [priorId, currentId]       (attached ^. #baseline) `shouldBe` Just (BaselineRef (hashContent "generated")) -    it "cannot strengthen additiveOnly while a prior owner survives" $ do+    it "cannot strengthen a closure requirement while a prior owner survives" $ do       -- This run executed only its own application's operations, so its       -- answer covers only its own contributions. A co-owner that rewrites       -- the whole file must keep the path under the ownership closure.       let priorId = ApplicationId "prior"           currentId = ApplicationId "current"-          prior = record (Set.singleton priorId) False-          current = record Set.empty True-      (attachApplication currentId (Just prior) current ^. #additiveOnly) `shouldBe` False+          prior = record (Set.singleton priorId) SharedWriteRequiresOwnershipClosure+          current = record Set.empty SharedWriteAdditiveOnly+      (attachApplication currentId (Just prior) current ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure -    it "keeps additiveOnly true when the prior record and this run agree" $ do+    it "keeps unknown evidence unknown while a prior owner survives" $ do+      -- Nothing this run did says how the retained owner writes the path.       let priorId = ApplicationId "prior"           currentId = ApplicationId "current"-      (attachApplication currentId (Just (record (Set.singleton priorId) True)) (record Set.empty True) ^. #additiveOnly)-        `shouldBe` True+      (attachApplication currentId (Just (record (Set.singleton priorId) SharedWriteUnknown)) (record Set.empty SharedWriteAdditiveOnly) ^. #sharedWriteMode)+        `shouldBe` SharedWriteUnknown -    it "weakens additiveOnly when this run is no longer additive" $ do+    it "keeps additive-only when the prior record and this run agree" $ do       let priorId = ApplicationId "prior"           currentId = ApplicationId "current"-      (attachApplication currentId (Just (record (Set.singleton priorId) True)) (record Set.empty False) ^. #additiveOnly)-        `shouldBe` False+      (attachApplication currentId (Just (record (Set.singleton priorId) SharedWriteAdditiveOnly)) (record Set.empty SharedWriteAdditiveOnly) ^. #sharedWriteMode)+        `shouldBe` SharedWriteAdditiveOnly +    it "records a closure requirement when this run is no longer additive" $ do+      let priorId = ApplicationId "prior"+          currentId = ApplicationId "current"+      forM_ [minBound .. maxBound] $ \priorMode ->+        (attachApplication currentId (Just (record (Set.singleton priorId) priorMode)) (record Set.empty SharedWriteRequiresOwnershipClosure) ^. #sharedWriteMode)+          `shouldBe` SharedWriteRequiresOwnershipClosure+     it "trusts this run's answer when it is the only owner" $ do       let currentId = ApplicationId "current"-          prior = record (Set.singleton currentId) False-      (attachApplication currentId (Just prior) (record Set.empty True) ^. #additiveOnly) `shouldBe` True-      (attachApplication currentId Nothing (record Set.empty True) ^. #additiveOnly) `shouldBe` True+      forM_ [minBound .. maxBound] $ \priorMode ->+        (attachApplication currentId (Just (record (Set.singleton currentId) priorMode)) (record Set.empty SharedWriteAdditiveOnly) ^. #sharedWriteMode)+          `shouldBe` SharedWriteAdditiveOnly+      (attachApplication currentId Nothing (record Set.empty SharedWriteAdditiveOnly) ^. #sharedWriteMode) `shouldBe` SharedWriteAdditiveOnly --- | A file record carrying the given owners and additive-only verdict.-record :: Set.Set ApplicationId -> Bool -> FileRecord-record owners additive =-  FileRecord (hashContent "content") "module" Template fixedTime Nothing owners additive+-- | A file record carrying the given owners and shared-write mode.+record :: Set.Set ApplicationId -> SharedWriteMode -> FileRecord+record owners mode =+  FileRecord (hashContent "content") "module" Template fixedTime Nothing owners mode
+ test/Seihou/Core/ArtifactIdentitySpec.hs view
@@ -0,0 +1,49 @@+module Seihou.Core.ArtifactIdentitySpec (tests) where++import Control.Monad (forM_)+import Data.Text (Text)+import Data.Text qualified as T+import Seihou.Core.ArtifactIdentity (isMachineLocalOriginUrl)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.Core.ArtifactIdentity" spec++machineLocal :: [Text]+machineLocal =+  [ "/Users/alice/Keikaku/bokuno/seihou-modules",+    "  /srv/modules  ",+    "./modules",+    "../seihou-modules",+    ".",+    "..",+    "~/src/seihou-modules",+    "~",+    "file:///Users/alice/seihou-modules",+    "file:../modules",+    "C:\\src\\modules",+    "c:/src/modules"+  ]++remote :: [Text]+remote =+  [ "https://github.com/shinzui/seihou-modules.git",+    "http://example.com/mods",+    "ssh://git@github.com/shinzui/seihou-modules.git",+    "git://example.com/mods.git",+    "git@github.com:shinzui/seihou-modules.git",+    "github.com:shinzui/seihou-modules",+    "modules"+  ]++spec :: Spec+spec =+  describe "isMachineLocalOriginUrl" $ do+    forM_ machineLocal $ \url ->+      it ("treats " <> T.unpack url <> " as machine-local") $+        isMachineLocalOriginUrl url `shouldBe` True+    forM_ remote $ \url ->+      it ("treats " <> T.unpack url <> " as reachable from other machines") $+        isMachineLocalOriginUrl url `shouldBe` False
test/Seihou/Core/ArtifactOriginDetectSpec.hs view
@@ -65,6 +65,16 @@         origin <- detectArtifactOrigin projectRoot moduleDir         origin `shouldBe` LocalOrigin "haskell-base" +    it "records an origin installed from a local path as a local origin" $ do+      withRoots $ \projectRoot installedRoot -> do+        let moduleDir = installedRoot </> "nix-haskell-flake"+        createDirectoryIfMissing True moduleDir+        writeFile+          (moduleDir </> ".seihou-origin.json")+          "{\"sourceUrl\":\"/Users/someone/src/modules\",\"repoName\":\"modules\"}"+        origin <- detectArtifactOrigin projectRoot moduleDir+        origin `shouldBe` LocalOrigin "nix-haskell-flake"+     it "falls back to a local origin when there is no metadata file" $ do       withRoots $ \projectRoot installedRoot -> do         let moduleDir = installedRoot </> "scratch-module"
+ test/Seihou/Core/CheckoutSpec.hs view
@@ -0,0 +1,95 @@+module Seihou.Core.CheckoutSpec (tests) where++import Control.Monad (forM_)+import Data.Aeson qualified as Aeson+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 Data.Time (UTCTime (..), fromGregorian)+import Seihou.Core.Checkout+import Seihou.Core.Types+import Seihou.Effect.Filesystem (PathEntry (..))+import Seihou.Manifest.Types+import Seihou.Manifest.Upgrade+import Seihou.Prelude+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++record :: FilePath -> Text -> CheckoutOutputRecord+record source app = CheckoutOutputRecord (DirectorySymlink source) (Map.singleton (ApplicationId app) (Set.singleton "demo"))++tests :: IO TestTree+tests = testSpec "checkout declarations and planning" $ do+  let dest = ".local/config"+      old = Map.singleton dest (record "config/editor" "a")+      next = Map.singleton dest (record "config/new" "a")+      obs entry = Map.singleton dest (CheckoutObservation entry True [] True True)+      actions intent prior candidate entry = map (^. #action) (planCheckoutOutputs intent SyncIgnoredOnly prior candidate (obs entry) ^. #entries)+  it "rejects portable path escapes, reserved metadata and overlapping paths" $+    forM_ ["", ".", "../escape", "a/../b", "/abs", "C:local", "a\\b", "a//b", "a/./b", "a/", ".GIT/config", ".seihou/x", "a\0b"] $ \source ->+      validateCheckoutPaths source dest `shouldSatisfy` isLeft+  it "rejects source/destination ancestry in either direction" $ do+    validateCheckoutPaths "config" "config/local" `shouldSatisfy` isLeft+    validateCheckoutPaths "config/local" "config" `shouldSatisfy` isLeft+  it "uses canonical relative link text" $ do+    expectedTarget ".local/config" (DirectorySymlink "config/editor") `shouldBe` "../config/editor"+    expectedTarget ".claude/skills/demo" (DirectorySymlink "agents/skills/demo") `shouldBe` "../../agents/skills/demo"+  it "merges agreeing claims associatively, commutatively and idempotently" $ do+    let add a = mergeCheckoutRecord dest (record "config/editor" a)+        merge names = foldl (\acc app -> acc >>= add app) (Right Map.empty) names+    merge ["a", "b", "c"] `shouldBe` merge ["c", "a", "b"]+    merge ["a", "a"] `shouldBe` merge ["a"]+    merge ["a", "b", "c"] `shouldBe` (merge ["a"] >>= add "b" >>= add "c")+  it "refuses conflicting owners regardless of contribution order" $ do+    mergeCheckoutRecord dest (record "other" "b") old `shouldSatisfy` isLeft+    mergeCheckoutRecord dest (record "config/editor" "a") (Map.singleton dest (record "other" "b")) `shouldSatisfy` isLeft+  it "drops only selected owners" $ do+    Right shared <- pure (mergeCheckoutRecord dest (record "config/editor" "b") old)+    dropCheckoutOwners (Map.singleton (ApplicationId "a") (Set.singleton "demo")) shared `shouldBe` Map.singleton dest (record "config/editor" "b")+  it "rebuilds missing and wrong links but preserves correct links" $ do+    actions Rebuild old old EntryMissing `shouldBe` [CreateLink]+    actions Rebuild old old (EntrySymlink "wrong") `shouldBe` [ReplaceLink]+    actions Rebuild old old (EntrySymlink "../config/editor") `shouldBe` [UnchangedLink]+  it "refuses real objects without discarding other classifications" $ do+    let records = Map.insert ".local/second" (record "assets/shared" "a") old+        observations = Map.insert ".local/second" (CheckoutObservation EntryMissing True [] True True) (obs EntryDirectory)+        plan = planCheckoutOutputs Rebuild SyncIgnoredOnly records records observations+    checkoutApplicable plan `shouldBe` False+    map (^. #action) (plan ^. #entries) `shouldBe` [RefusedLink, CreateLink]+  it "does no repair for unchanged declarations or owner-only changes" $ do+    actions Reconcile old old EntryMissing `shouldBe` [UnchangedLink]+    actions Reconcile old (Map.singleton dest (record "config/editor" "b")) (EntrySymlink "edited") `shouldBe` [UnchangedLink]+  it "retires only matching links even with a missing source" $ do+    let observations = Map.singleton dest (CheckoutObservation (EntrySymlink "../config/editor") False [] True False)+    map (^. #action) (planCheckoutOutputs Reconcile ApplyDeclaration old Map.empty observations ^. #entries) `shouldBe` [RetireLink]+    actions Reconcile old Map.empty (EntrySymlink "edited") `shouldBe` [PreserveModified]+    actions Reconcile old Map.empty EntryDirectory `shouldBe` [PreserveModified]+    actions Reconcile old Map.empty EntryMissing `shouldBe` [AlreadyAbsent]+  it "retargets old or already-correct links but refuses third targets" $ do+    actions Reconcile old next (EntrySymlink "../config/editor") `shouldBe` [ReplaceLink]+    actions Reconcile old next (EntrySymlink "../config/new") `shouldBe` [UnchangedLink]+    actions Reconcile old next (EntrySymlink "edited") `shouldBe` [RefusedLink]+  it "rejects a rebuild contract with changed declarations" $+    checkoutApplicable (planCheckoutOutputs Rebuild SyncIgnoredOnly old next (obs EntryMissing)) `shouldBe` False+  it "keeps the warning policy distinct from sync's ignored-only policy" $ do+    let observations = Map.singleton dest (CheckoutObservation EntryMissing True [] False False)+    checkoutApplicable (planCheckoutOutputs Rebuild SyncIgnoredOnly old old observations) `shouldBe` False+    let apply = planCheckoutOutputs Reconcile ApplyDeclaration Map.empty old observations+    checkoutApplicable apply `shouldBe` True+    concatMap (^. #warnings) (apply ^. #entries) `shouldSatisfy` (not . null)+  it "adds schema 9 losslessly without inventing declarations" $ do+    let now = UTCTime (fromGregorian 2026 10 15) 0+        original = emptyManifest now & #version .~ ManifestSchemaVersion 8+    Right (_, document) <- pure (upgradeDocumentLosslessly (ManifestSchemaVersion 9) =<< decode original)+    case manifestFromJSON (encode document) of+      Right result -> Map.null (result ^. #checkoutOutputs) `shouldBe` True+      Left err -> expectationFailure err+  it "rejects duplicate keys before JSON object construction, including escaped-equivalent keys" $ do+    manifestFromJSON "{\"version\":9,\"version\":9}" `shouldSatisfy` isLeft+    manifestFromJSON "{\"version\":9,\"owners\":{\"a\":[],\"\\u0061\":[]}}" `shouldSatisfy` either (T.isInfixOf "duplicate" . T.pack) (const False)+  where+    decode manifest = first (const (MalformedDocument (ManifestSchemaVersion 8) "invalid fixture")) (Aeson.eitherDecode (manifestToJSON manifest))+    encode = Aeson.encode
test/Seihou/Core/ModuleSpec.hs view
@@ -40,7 +40,8 @@               src = "README.md.tpl",               dest = "README.md",               condition = Nothing,-              patch = Nothing+              patch = Nothing,+              lifecycle = Managed             }         ],       commands = [],@@ -180,7 +181,7 @@         let bad =               goodModule                 & #steps-                  .~ [Step Template "README.md.tpl" "../etc/passwd" Nothing Nothing]+                  .~ [Step Template "README.md.tpl" "../etc/passwd" Nothing Nothing Managed]         result <- validateModule tmpDir bad         case result of           Left (ValidationError _ errs) ->@@ -195,7 +196,7 @@         let bad =               goodModule                 & #steps-                  .~ [Step Template "README.md.tpl" "/etc/passwd" Nothing Nothing]+                  .~ [Step Template "README.md.tpl" "/etc/passwd" Nothing Nothing Managed]         result <- validateModule tmpDir bad         case result of           Left (ValidationError _ errs) ->@@ -210,7 +211,7 @@         let dotted =               goodModule                 & #steps-                  .~ [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing]+                  .~ [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing Managed]         result <- validateModule tmpDir dotted         case result of           Right m -> (m ^. #name) `shouldBe` "test-module"@@ -223,7 +224,7 @@         let bad =               goodModule                 & #steps-                  .~ [Step Template "README.md.tpl" "src/{{unknown}}/Main.hs" Nothing Nothing]+                  .~ [Step Template "README.md.tpl" "src/{{unknown}}/Main.hs" Nothing Nothing Managed]         result <- validateModule tmpDir bad         case result of           Left (ValidationError _ errs) ->@@ -241,7 +242,7 @@                   vars = [],                   exports = [VarExport {var = "missing", alias = Nothing}],                   prompts = [Prompt {var = "missing", text = "?", condition = Nothing, choices = Nothing}],-                  steps = [Step Template "nonexistent.tpl" "/bad/dest" Nothing Nothing],+                  steps = [Step Template "nonexistent.tpl" "/bad/dest" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,
+ test/Seihou/Core/SeedSpec.hs view
@@ -0,0 +1,71 @@+module Seihou.Core.SeedSpec (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 Data.Text.IO qualified as TIO+import Data.Time (UTCTime (..), fromGregorian)+import Seihou.Core.Module (checkSeedSteps)+import Seihou.Core.Seed+import Seihou.Core.Types+import Seihou.Dhall.Eval (evalModuleFromFile)+import Seihou.Manifest.Types+import Seihou.Prelude+import System.Directory (getCurrentDirectory)+import System.FilePath (takeDirectory)+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++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+    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+  it "round trips receipts without hashes or baselines" $ do+    manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest+  it "rejects a managed/seed collision at decoding" $ do+    let file = FileRecord (SHA256 "hash") "demo" Copy now Nothing Set.empty SharedWriteUnknown+        invalid = manifest & #files .~ Map.singleton "CHANGELOG.md" file+    validateManifestInvariants invalid `shouldBe` Left (PathBothManagedAndSeeded "CHANGELOG.md")+    manifestFromJSON (manifestToJSON invalid) `shouldSatisfy` isLeft+  it "keeps schema 7 unchanged and diagnoses seed information loss" $ do+    let old = emptyManifest now & #version .~ ManifestSchemaVersion 7+    manifestFromJSON (manifestToJSON old) `shouldBe` Right old+  it "reports facts an older schema cannot encode" $ do+    manifestEncodingLoss (manifest & #version .~ ManifestSchemaVersion 7) `shouldSatisfy` (not . null)+  it "decodes omitted lifecycle and rejects invalid lifecycle values" $+    withSystemTempDirectory "lifecycle" $ \dir -> do+      cwd <- getCurrentDirectory+      let schema = takeDirectory cwd </> "schema/package.dhall"+          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 <> " }] }"+              ]+      TIO.writeFile (dir </> "module.dhall") (source "")+      Right old <- evalModuleFromFile (dir </> "module.dhall")+      map (^. #lifecycle) (old ^. #steps) `shouldBe` [Managed]+      TIO.writeFile (dir </> "module.dhall") (source ", lifecycle = Some \"seed\"")+      Right seed <- evalModuleFromFile (dir </> "module.dhall")+      checkSeedSteps seed `shouldBe` []+      let patched = seed & #steps %~ map (#patch ?~ AppendFile)+      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)+      TIO.writeFile (dir </> "module.dhall") (source ", lifecycle = Some \"sede\"")+      invalid <- evalModuleFromFile (dir </> "module.dhall")+      invalid `shouldSatisfy` isLeft
test/Seihou/Core/StatusSpec.hs view
@@ -33,7 +33,7 @@       generatedAt = fixedTime,       baseline = Nothing,       applicationIds = mempty,-      additiveOnly = False+      sharedWriteMode = SharedWriteUnknown     }  runStatus :: PureFS -> Manifest -> [TrackedFile]@@ -49,7 +49,7 @@             ( (emptyManifest fixedTime :: Manifest)                 & #files .~ Map.singleton "README.md" (mkRecord content)             )-          fs = PureFS (Map.singleton "README.md" content) mempty+          fs = PureFS (Map.singleton "README.md" content) mempty Map.empty           result = runStatus fs manifest       length result `shouldBe` 1       ((head result) ^. #path) `shouldBe` "README.md"@@ -63,7 +63,7 @@             ( (emptyManifest fixedTime :: Manifest)                 & #files .~ Map.singleton "README.md" (mkRecord originalContent)             )-          fs = PureFS (Map.singleton "README.md" modifiedContent) mempty+          fs = PureFS (Map.singleton "README.md" modifiedContent) mempty Map.empty           result = runStatus fs manifest       length result `shouldBe` 1       ((head result) ^. #status) `shouldBe` TfsModified@@ -95,6 +95,7 @@                   ]               )               mempty+              Map.empty           result = runStatus fs manifest       length result `shouldBe` 3       -- Results are sorted by path
test/Seihou/Effect/FilesystemSpec.hs view
@@ -48,7 +48,7 @@       exists `shouldBe` True      it "copyFile copies content" $ do-      let initial = PureFS (Map.singleton "src.txt" "source content") Set.empty+      let initial = PureFS (Map.singleton "src.txt" "source content") Set.empty Map.empty           (content, _) = runPure initial $ do             copyFile "src.txt" "dest.txt"             readFileText "dest.txt"
test/Seihou/Effect/ManifestStoreSpec.hs view
@@ -33,7 +33,7 @@   (emptyManifest fixedTime)     & #modules .~ [AppliedModule (ModuleName "haskell-base") emptyParentVars (LocalOrigin "haskell-base") Nothing fixedTime Nothing]     & #vars .~ Map.fromList [(VarName "project.name", "my-app")]-    & #files .~ Map.fromList [("README.md", FileRecord (SHA256 "abc123") (ModuleName "haskell-base") Template fixedTime Nothing mempty False)]+    & #files .~ Map.fromList [("README.md", FileRecord (SHA256 "abc123") (ModuleName "haskell-base") Template fixedTime Nothing mempty SharedWriteUnknown)]  spec :: Spec spec = do@@ -88,7 +88,7 @@                 runManifestStore manifestPath (writeManifest sampleManifest)                 c <- readFileText manifestPath                 pure ((), c)-      T.isInfixOf "\"version\":6" content `shouldBe` True+      T.isInfixOf "\"version\":9" content `shouldBe` True       T.isInfixOf "haskell-base" content `shouldBe` True       T.isInfixOf "my-app" content `shouldBe` True @@ -104,7 +104,7 @@      it "returns Left for corrupt JSON" $ do       let manifestPath = ".seihou/manifest.json"-          corruptFS = PureFS (Map.fromList [(manifestPath, "{ this is not json }")]) Set.empty+          corruptFS = PureFS (Map.fromList [(manifestPath, "{ this is not json }")]) Set.empty Map.empty           (result, _) =             runPureEff $               runFilesystemPure corruptFS $
test/Seihou/Engine/BaselineSpec.hs view
@@ -38,9 +38,9 @@                 generatedAt = fixedTime,                 baseline = Nothing,                 applicationIds = Set.singleton applicationId,-                additiveOnly = False+                sharedWriteMode = SharedWriteUnknown               }-          initialFS = PureFS (Map.singleton ("/project/" <> path) content) Set.empty+          initialFS = PureFS (Map.singleton ("/project/" <> path) content) Set.empty Map.empty           ((result, stored), _) =             runPureEff $               runFilesystemPure initialFS $@@ -57,10 +57,10 @@           Map.lookup expectedRef stored `shouldBe` Just content      it "returns an error and publishes no reference when a generated file is missing" $ do-      let record = FileRecord (hashContent "planned") "module" Template fixedTime Nothing Set.empty False+      let record = FileRecord (hashContent "planned") "module" Template fixedTime Nothing Set.empty SharedWriteUnknown           ((result, stored), _) =             runPureEff $-              runFilesystemPure (PureFS Map.empty Set.empty) $+              runFilesystemPure (PureFS Map.empty Set.empty Map.empty) $                 runBaselineStorePure Map.empty $                   recordGeneratedBaselines "/project" (Map.singleton "missing.txt" record)       result `shouldSatisfy` isStoreFailure@@ -68,8 +68,8 @@      it "stores content that can be read back through the baseline effect" $ do       let content = "round trip"-          record = FileRecord (hashContent content) "module" Copy fixedTime Nothing Set.empty False-          initialFS = PureFS (Map.singleton "copy.txt" content) Set.empty+          record = FileRecord (hashContent content) "module" Copy fixedTime Nothing Set.empty SharedWriteUnknown+          initialFS = PureFS (Map.singleton "copy.txt" content) Set.empty Map.empty           ((result, readBack), _) =             runPureEff $               runFilesystemPure initialFS $@@ -87,7 +87,7 @@     it "collects and deduplicates every referenced blob" $ do       let first = baselineRefForContent "first"           second = baselineRefForContent "second"-          mkRecord ref = FileRecord (hashContent "applied") "module" Template fixedTime ref Set.empty False+          mkRecord ref = FileRecord (hashContent "applied") "module" Template fixedTime ref Set.empty SharedWriteUnknown           manifest :: Manifest           manifest =             ( (emptyManifest fixedTime)
+ test/Seihou/Engine/CheckoutSpec.hs view
@@ -0,0 +1,213 @@+module Seihou.Engine.CheckoutSpec (tests) where++import Control.Monad (forM_, when)+import Data.ByteString.Lazy qualified as LBS+import Data.Either (isLeft)+import Data.Generics.Labels ()+import Data.IORef+import Data.Map.Strict qualified as Map+import Data.Maybe (isJust)+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 (..), fromGregorian)+import Seihou.Core.Checkout+import Seihou.Core.Module (checkFileExistence, validateModule)+import Seihou.Core.Types+import Seihou.Dhall.Eval (evalModuleFromFile)+import Seihou.Effect.Filesystem+import Seihou.Effect.FilesystemInterp (runFilesystem)+import Seihou.Effect.Process+import Seihou.Effect.ProcessInterp (runProcessIO)+import Seihou.Engine.Checkout+import Seihou.Engine.Plan (compilePlan)+import Seihou.Engine.Remove (CheckoutRemovalSelection (..), selectCheckoutRemoval)+import Seihou.Manifest.Types+import Seihou.Prelude+import System.Directory qualified as D+import System.Exit (ExitCode (..))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (readProcessWithExitCode)+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++fixtureManifest :: Manifest+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+    record source = CheckoutOutputRecord (DirectorySymlink source) (Map.singleton (ApplicationId "a") (Set.singleton "demo"))++withFixture :: (FilePath -> IO a) -> IO a+withFixture action = withSystemTempDirectory "checkout-contract" $ \temp -> do+  root <- D.canonicalizePath temp+  (code, _, _) <- readProcessWithExitCode "git" ["-C", root, "init", "-q"] ""+  code `shouldBe` ExitSuccess+  forM_ ["config/editor", "assets/shared"] (D.createDirectoryIfMissing True . (root </>))+  TIO.writeFile (root </> ".gitignore") ".local/\n.tool/\n"+  TIO.writeFile (root </> "config/editor/settings") "sentinel"+  action root++prepare root intent old new = runEff $ runFilesystem $ runProcessIO $ prepareCheckoutPlan intent SyncIgnoredOnly root old new []++apply root plan = runEff $ runFilesystem $ runProcessIO $ applyCheckoutPlan SyncIgnoredOnly root plan++tests :: IO TestTree+tests = testSpec "checkout filesystem contract" $ do+  it "requires application selection for ambiguous owners and preserves unselected claims" $ do+    let [app] = fixtureManifest ^. #applications+        second = app & #applicationId .~ ApplicationId "b"+        shared = fixtureManifest & #applications .~ [app, second] & #checkoutOutputs %~ Map.map (#owners %~ Map.insert (ApplicationId "b") (Set.singleton "demo"))+    selectCheckoutRemoval shared "demo" Nothing `shouldSatisfy` isLeft+    Right (Just selected) <- pure (selectCheckoutRemoval shared "demo" (Just (ApplicationId "a")))+    let retained = selected ^. #candidateManifest+    map (^. #applicationId) (retained ^. #applications) `shouldBe` [ApplicationId "b"]+    map (Map.keys . (^. #owners)) (Map.elems (retained ^. #checkoutOutputs)) `shouldBe` [[ApplicationId "b"], [ApplicationId "b"]]+  it "rejects native spelling aliases when the temporary volume is case-insensitive" $ withFixture $ \root -> do+    insensitive <- D.doesDirectoryExist (root </> ".GIT")+    when insensitive $ do+      let aliased = fixtureManifest & #checkoutOutputs %~ Map.adjust (#spec .~ DirectorySymlink "config/Editor") ".local/config"+      Right plan <- prepare root Rebuild aliased aliased+      checkoutApplicable plan `shouldBe` False+      concatMap (map (^. #code) . (^. #errors)) (plan ^. #entries) `shouldContain` ["checkout_native_alias"]+  it "compiles project sources without an installed files source and validates rendered paths" $ withFixture $ \root -> do+    let modul = Module "demo" (Just "1.0.0") Nothing [VarDecl "source" VTText Nothing Nothing True Nothing] [] [] [Step Symlink "{{source}}" ".local/config" Nothing Nothing Checkout] [] [] Nothing []+    checkFileExistence root modul `shouldReturn` []+    compiled <- compilePlan root modul (Map.singleton "source" (VText "config/editor"))+    compiled `shouldBe` Right [CheckoutOutputOp ".local/config" (DirectorySymlink "config/editor") "demo"]+    escaped <- compilePlan root modul (Map.singleton "source" (VText "../outside"))+    escaped `shouldSatisfy` isLeft+    invalid <- validateModule root (modul & #steps %~ map (#lifecycle .~ Managed))+    invalid `shouldSatisfy` isLeft+    skipped <- compilePlan root (modul & #steps %~ map (#condition ?~ ExprLit False)) Map.empty+    skipped `shouldBe` Right []+  it "traces only declared link writes and parent creation, with fixed read-only Git calls" $ withFixture $ \root -> do+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    writes <- newIORef []+    processes <- newIORef []+    applied <- runEff $ tracedFilesystem writes $ tracedProcess processes $ applyCheckoutPlan SyncIgnoredOnly root plan+    applied ^. #failure `shouldBe` Nothing+    actualWrites <- readIORef writes+    Set.fromList actualWrites `shouldBe` Set.fromList [("mkdir", root </> ".local"), ("mkdir", root </> ".tool"), ("link", root </> ".local/config"), ("link", root </> ".tool/assets")]+    actualProcesses <- readIORef processes+    actualProcesses+      `shouldSatisfy` all+        ( \(cmd, args) ->+            cmd == "git" && case args of+              ["rev-parse", "--show-toplevel"] -> True+              ["ls-files", "--cached", "-z"] -> True+              ["check-ignore", "-q", "--", path] -> "./" `T.isPrefixOf` path+              _ -> False+        )+  it "creates portable links and converges without following their contents" $ withFixture $ \root -> do+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    map (^. #action) (plan ^. #entries) `shouldBe` [CreateLink, CreateLink]+    result <- apply root plan+    result ^. #failure `shouldBe` Nothing+    D.getSymbolicLinkTarget (root </> ".local/config") `shouldReturn` "../config/editor"+    TIO.readFile (root </> "config/editor/settings") `shouldReturn` "sentinel"+    Right repeated <- prepare root Rebuild fixtureManifest fixtureManifest+    map (^. #action) (repeated ^. #entries) `shouldBe` [UnchangedLink, UnchangedLink]+  it "repairs dangling wrong links and leaves sentinel target bytes alone" $ withFixture $ \root -> do+    D.createDirectoryIfMissing True (root </> ".local")+    D.createDirectoryLink "../missing" (root </> ".local/config")+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    map (^. #action) (plan ^. #entries) `shouldBe` [ReplaceLink, CreateLink]+    result <- apply root plan+    result ^. #failure `shouldBe` Nothing+  it "refuses every mutation when any destination is a real object" $ withFixture $ \root -> do+    D.createDirectoryIfMissing True (root </> ".local/config")+    TIO.writeFile (root </> ".local/config/sentinel") "keep"+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    checkoutApplicable plan `shouldBe` False+    result <- apply root plan+    result ^. #failure `shouldSatisfy` isJust+    D.doesPathExist (root </> ".tool") `shouldReturn` False+    TIO.readFile (root </> ".local/config/sentinel") `shouldReturn` "keep"+  it "refuses symlink ancestors and missing sources" $ withFixture $ \root -> do+    D.createDirectoryLink "assets" (root </> ".local")+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    checkoutApplicable plan `shouldBe` False+    D.removeDirectoryLink (root </> ".local")+    D.removeDirectory (root </> "assets/shared")+    Right missing <- prepare root Rebuild fixtureManifest fixtureManifest+    checkoutApplicable missing `shouldBe` False+  it "protects ignored tracked paths even when absent on disk" $ withFixture $ \root -> do+    D.createDirectoryIfMissing True (root </> ".local")+    TIO.writeFile (root </> ".local/config") "tracked"+    (code, _, _) <- readProcessWithExitCode "git" ["-C", root, "add", "-f", "--", ".local/config"] ""+    code `shouldBe` ExitSuccess+    D.removeFile (root </> ".local/config")+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    checkoutApplicable plan `shouldBe` False+    concatMap (map (^. #code) . (^. #errors)) (plan ^. #entries) `shouldContain` ["checkout_path_tracked"]+  it "reports unsupported symlink creation without copying source data" $ withFixture $ \root -> do+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    result <- runEff $ unsupportedLinks $ runProcessIO $ applyCheckoutPlan SyncIgnoredOnly root plan+    result ^. #failure `shouldSatisfy` isJust+    case result ^. #failure of+      Just err -> err ^. #message `shouldSatisfy` T.isInfixOf "symlink permissions"+      Nothing -> expectationFailure "expected a creation error"+    D.doesPathExist (root </> ".local") `shouldReturn` False+    TIO.readFile (root </> "config/editor/settings") `shouldReturn` "sentinel"+  it "rolls back an ordinary mid-apply failure and supports retry" $ withFixture $ \root -> do+    Right plan <- prepare root Rebuild fixtureManifest fixtureManifest+    result <- runEff $ runFilesystem $ runProcessIO $ applyCheckoutPlanWithHook (\n -> when (n == 1) (liftIO (ioError (userError "injected")))) SyncIgnoredOnly root plan+    result ^. #failure `shouldSatisfy` isJust+    D.doesPathExist (root </> ".local") `shouldReturn` False+    retried <- apply root plan+    retried ^. #failure `shouldBe` Nothing+  it "retires a dangling last-owner link without checking its source" $ withFixture $ \root -> do+    Right first <- prepare root Rebuild fixtureManifest fixtureManifest+    _ <- apply root first+    D.removeFile (root </> "config/editor/settings")+    D.removeDirectory (root </> "config/editor")+    let candidate = fixtureManifest & #checkoutOutputs %~ Map.delete ".local/config"+    Right retired <- prepare root Reconcile fixtureManifest candidate+    map (^. #action) (retired ^. #entries) `shouldBe` [RetireLink, UnchangedLink]+    result <- apply root retired+    result ^. #failure `shouldBe` Nothing+    D.pathIsSymbolicLink (root </> ".tool/assets") `shouldReturn` True+  it "rejects unknown specs and bad owners at the byte decoding boundary" $ do+    let bytes = manifestToJSON fixtureManifest+    manifestFromJSON bytes `shouldBe` Right fixtureManifest+    manifestFromJSON (replace "directory-symlink" "invented-kind" bytes) `shouldSatisfy` isLeft+    manifestFromJSON (manifestToJSON (fixtureManifest & #applications .~ [])) `shouldSatisfy` isLeft+  where+    replace a b = LBS.fromStrict . TE.encodeUtf8 . T.replace a b . TE.decodeUtf8 . LBS.toStrict++-- These first-order effects can be reissued in an isolated real interpreter.+-- The trace fails on any text read/write, copy, rename or recursive removal.+tracedFilesystem :: (IOE :> es) => IORef [(Text, FilePath)] -> Eff (Filesystem : es) a -> Eff es a+tracedFilesystem trace = interpret $ \_ operation -> do+  case operation of+    CreateDirectoryIfMissing _ path -> liftIO (modifyIORef' trace (("mkdir", path) :))+    CreateDirectoryLink _ path -> liftIO (modifyIORef' trace (("link", path) :))+    RemoveLink path -> liftIO (modifyIORef' trace (("unlink", path) :))+    InspectPath _ -> pure ()+    ListDirectory _ -> pure ()+    NormalizePath _ -> pure ()+    _ -> liftIO (ioError (userError "checkout attempted an unexpected filesystem effect"))+  liftIO $ runEff $ runFilesystem $ reissue operation++reissue :: Filesystem m a -> Eff '[Filesystem, IOE] a+reissue (CreateDirectoryIfMissing parents path) = createDirectoryIfMissing parents path+reissue (CreateDirectoryLink target path) = createLink target path+reissue (RemoveLink path) = removeLink path+reissue (InspectPath path) = inspectPath path+reissue (ListDirectory path) = listDirectory path+reissue (NormalizePath path) = normalizePath path+reissue (RemoveDirectoryIfEmpty path) = removeDirectoryIfEmpty path+reissue _ = liftIO (ioError (userError "unexpected filesystem effect"))++tracedProcess :: (IOE :> es) => IORef [(Text, [Text])] -> Eff (Process : es) a -> Eff es a+tracedProcess trace = interpret $ \_ (RunProcess cmd args root) -> do+  liftIO (modifyIORef' trace ((cmd, args) :))+  liftIO $ runEff $ runProcessIO $ runProcess cmd args root++unsupportedLinks :: (IOE :> es) => Eff (Filesystem : es) a -> Eff es a+unsupportedLinks = interpret $ \_ operation -> case operation of+  CreateDirectoryLink _ _ -> liftIO (ioError (userError "symlink creation unsupported"))+  _ -> liftIO $ runEff $ runFilesystem $ reissue operation
test/Seihou/Engine/DiffSpec.hs view
@@ -41,7 +41,7 @@       generatedAt = fixedTime,       baseline = Nothing,       applicationIds = mempty,-      additiveOnly = False+      sharedWriteMode = SharedWriteUnknown     }  -- | Helper to create a manifest with file records (avoids ambiguous record update).@@ -57,6 +57,8 @@           applications = base ^. #applications,           recipe = base ^. #recipe,           blueprint = base ^. #blueprint,+          seeds = base ^. #seeds,+          checkoutOutputs = base ^. #checkoutOutputs,           blueprintMigrations = base ^. #blueprintMigrations         } @@ -75,7 +77,7 @@     it "classifies file in plan + on disk (not in manifest) as Conflict" $ do       let manifest = emptyManifest fixedTime           planned = [("README.md", "# Hello", modName, Nothing)]-          fs = PureFS (Map.singleton "README.md" "existing content") mempty+          fs = PureFS (Map.singleton "README.md" "existing content") mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #conflicts) `shouldBe` 1       ((head (result ^. #conflicts)) ^. #path) `shouldBe` "README.md"@@ -83,7 +85,7 @@     it "classifies patch op on existing file (not in manifest) as New, not Conflict" $ do       let manifest = emptyManifest fixedTime           planned = [(".gitignore", ".claude/\n", modName, Just AppendSection)]-          fs = PureFS (Map.singleton ".gitignore" ".seihou/\n") mempty+          fs = PureFS (Map.singleton ".gitignore" ".seihou/\n") mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #conflicts) `shouldBe` 0       length (result ^. #new) `shouldBe` 1@@ -95,7 +97,7 @@           patchContent = "new section"           manifest = manifestWithFiles (Map.singleton "config.txt" (mkRecord originalContent))           planned = [("config.txt", patchContent, modName, Just AppendSection)]-          fs = PureFS (Map.singleton "config.txt" userContent) mempty+          fs = PureFS (Map.singleton "config.txt" userContent) mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #conflicts) `shouldBe` 0       length (result ^. #modified) `shouldBe` 1@@ -105,7 +107,7 @@       let content = "# Hello World"           manifest = manifestWithFiles (Map.singleton "README.md" (mkRecord content))           planned = [("README.md", content, modName, Nothing)]-          fs = PureFS (Map.singleton "README.md" content) mempty+          fs = PureFS (Map.singleton "README.md" content) mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #unchanged) `shouldBe` 1       head (result ^. #unchanged) `shouldBe` "README.md"@@ -116,7 +118,7 @@           manifest = manifestWithFiles (Map.singleton "README.md" (mkRecord oldContent))           planned = [("README.md", newContent, modName, Nothing)]           -- Disk matches manifest (user didn't touch it)-          fs = PureFS (Map.singleton "README.md" oldContent) mempty+          fs = PureFS (Map.singleton "README.md" oldContent) mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #modified) `shouldBe` 1       ((head (result ^. #modified)) ^. #path) `shouldBe` "README.md"@@ -129,7 +131,7 @@           manifest = manifestWithFiles (Map.singleton "README.md" (mkRecord originalContent))           planned = [("README.md", planContent, modName, Nothing)]           -- Disk was modified by user (doesn't match manifest)-          fs = PureFS (Map.singleton "README.md" userContent) mempty+          fs = PureFS (Map.singleton "README.md" userContent) mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #conflicts) `shouldBe` 1       ((head (result ^. #conflicts)) ^. #path) `shouldBe` "README.md"@@ -142,7 +144,7 @@                 & #files .~ Map.singleton "old-file.txt" (mkRecord content)             )           planned = [] :: [(FilePath, Text, ModuleName, Maybe PatchOp)] -- module no longer produces this file-          fs = PureFS (Map.singleton "old-file.txt" content) mempty+          fs = PureFS (Map.singleton "old-file.txt" content) mempty Map.empty           result = runDiff fs manifest active planned       length (result ^. #orphaned) `shouldBe` 1       ((head (result ^. #orphaned)) ^. #path) `shouldBe` "old-file.txt"@@ -183,6 +185,7 @@             PureFS               (Map.fromList [("unchanged.txt", existingContent), ("orphaned.txt", "orphan")])               mempty+              Map.empty           result = runDiff fs manifest active planned       length (result ^. #new) `shouldBe` 1       length (result ^. #unchanged) `shouldBe` 1@@ -210,12 +213,12 @@                 generatedAt = fixedTime,                 baseline = Nothing,                 applicationIds = mempty,-                additiveOnly = False+                sharedWriteMode = SharedWriteUnknown               }           manifest = manifestWithFiles (Map.singleton "other.txt" record)           planned = [("new.txt", "new content", modName, Nothing)]           activeModules = Set.singleton modName -- "test-module", NOT "other-module"-          fs = PureFS (Map.singleton "other.txt" content) mempty+          fs = PureFS (Map.singleton "other.txt" content) mempty Map.empty           result = runDiff fs manifest activeModules planned       length (result ^. #orphaned) `shouldBe` 0       length (result ^. #new) `shouldBe` 1@@ -225,7 +228,7 @@           manifest = manifestWithFiles (Map.singleton "old.txt" (mkRecord content))           planned = [("new.txt", "new content", modName, Nothing)]           activeModules = Set.singleton modName -- file belongs to active module-          fs = PureFS (Map.singleton "old.txt" content) mempty+          fs = PureFS (Map.singleton "old.txt" content) mempty Map.empty           result = runDiff fs manifest activeModules planned       length (result ^. #orphaned) `shouldBe` 1       ((head (result ^. #orphaned)) ^. #path) `shouldBe` "old.txt"@@ -240,7 +243,7 @@                 generatedAt = fixedTime,                 baseline = Nothing,                 applicationIds = mempty,-                additiveOnly = False+                sharedWriteMode = SharedWriteUnknown               }           manifest =             manifestWithFiles@@ -256,6 +259,7 @@             PureFS               (Map.fromList [("active-old.txt", "active old"), ("other.txt", "other content")])               mempty+              Map.empty           result = runDiff fs manifest activeModules planned       -- active-old.txt is orphaned (active module no longer produces it)       length (result ^. #orphaned) `shouldBe` 1@@ -274,13 +278,13 @@                 generatedAt = fixedTime,                 baseline = Nothing,                 applicationIds = mempty,-                additiveOnly = False+                sharedWriteMode = SharedWriteUnknown               }           manifest = manifestWithFiles (Map.singleton "shared.txt" otherRecord)           -- active module wants to write to same path owned by inactive module           planned = [("shared.txt", "new content from active", modName, Nothing)]           activeModules = Set.singleton modName-          fs = PureFS (Map.singleton "shared.txt" "other content") mempty+          fs = PureFS (Map.singleton "shared.txt" "other content") mempty Map.empty           result = runDiff fs manifest activeModules planned       -- File exists on disk but not in active manifest → Conflict       length (result ^. #conflicts) `shouldBe` 1@@ -298,7 +302,7 @@                 generatedAt = fixedTime,                 baseline = Nothing,                 applicationIds = mempty,-                additiveOnly = False+                sharedWriteMode = SharedWriteUnknown               }           manifest =             manifestWithFiles@@ -320,6 +324,7 @@                   ]               )               mempty+              Map.empty           result = runDiff fs manifest activeModules planned       -- from-a.txt unchanged (active, still produced)       length (result ^. #unchanged) `shouldBe` 1
test/Seihou/Engine/ExecuteSpec.hs view
@@ -95,7 +95,7 @@       Map.size records `shouldBe` 0      it "handles CopyFileOp by reading source and writing dest" $ do-      let initial = PureFS (Map.singleton "/source/file.txt" "copied content") mempty+      let initial = PureFS (Map.singleton "/source/file.txt" "copied content") mempty Map.empty           ops = [CopyFileOp "/source/file.txt" "dest.txt"]           (records, fs) = runExecFS initial ops       Map.member "dest.txt" records `shouldBe` True@@ -126,7 +126,7 @@       (record ^. #strategy) `shouldBe` Structured      it "executes PatchFileOp AppendFile on existing file" $ do-      let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty+      let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty Map.empty           ops = [PatchFileOp "README.md" "extra line\n" AppendFile Template modName]           (records, fs) = runExecFS initial ops       Map.member "README.md" records `shouldBe` True@@ -135,7 +135,7 @@       T.isInfixOf "extra line" content `shouldBe` True      it "executes PatchFileOp PrependFile on existing file" $ do-      let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty+      let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty Map.empty           ops = [PatchFileOp "README.md" "header\n" PrependFile Template modName]           (records, fs) = runExecFS initial ops       Map.member "README.md" records `shouldBe` True@@ -144,7 +144,7 @@       T.isInfixOf "# Title" content `shouldBe` True      it "executes PatchFileOp AppendSection with section markers" $ do-      let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty+      let initial = PureFS (Map.singleton "/project/README.md" "# Title\n") mempty Map.empty           ops = [PatchFileOp "README.md" "section content\n" AppendSection Template modName]           (records, fs) = runExecFS initial ops       Map.member "README.md" records `shouldBe` True@@ -161,7 +161,7 @@       T.isInfixOf "new content" content `shouldBe` True      it "executes PatchFileOp AppendLineIfAbsent, skipping existing lines" $ do-      let initial = PureFS (Map.singleton "/project/.gitignore" "node_modules/\n.env\n") mempty+      let initial = PureFS (Map.singleton "/project/.gitignore" "node_modules/\n.env\n") mempty Map.empty           ops = [PatchFileOp ".gitignore" ".env\n.claude/\n" AppendLineIfAbsent Template modName]           (records, fs) = runExecFS initial ops       Map.member ".gitignore" records `shouldBe` True@@ -169,42 +169,42 @@       content `shouldBe` "node_modules/\n.env\n.claude/\n"      it "executes PatchFileOp AppendLineIfAbsent idempotently" $ do-      let initial = PureFS (Map.singleton "/project/.gitignore" "node_modules/\n.claude/\n") mempty+      let initial = PureFS (Map.singleton "/project/.gitignore" "node_modules/\n.claude/\n") mempty Map.empty           ops = [PatchFileOp ".gitignore" ".claude/\n" AppendLineIfAbsent Template modName]           (records, fs) = runExecFS initial ops       Map.member ".gitignore" records `shouldBe` True       let content = (fs ^. #files) Map.! "/project/.gitignore"       content `shouldBe` "node_modules/\n.claude/\n" -  describe "executePlan additiveOnly" $ do+  describe "executePlan sharedWriteMode" $ do     it "records an additive patch as additive-only" $ do       let ops = [PatchFileOp ".gitignore" "/result\n" AppendLineIfAbsent Template modName]           (records, _) = runExecFS emptyFS ops-      ((records Map.! ".gitignore") ^. #additiveOnly) `shouldBe` True+      ((records Map.! ".gitignore") ^. #sharedWriteMode) `shouldBe` SharedWriteAdditiveOnly      it "records an appended section as additive-only" $ do       let ops = [PatchFileOp ".gitignore" "/dist\n" AppendSection Template modName]           (records, _) = runExecFS emptyFS ops-      ((records Map.! ".gitignore") ^. #additiveOnly) `shouldBe` True+      ((records Map.! ".gitignore") ^. #sharedWriteMode) `shouldBe` SharedWriteAdditiveOnly      it "does not record a position-dependent patch as additive-only" $ do       -- AppendFile and PrependFile place bytes relative to whatever is       -- already in the file, so replaying one owner can reorder the result.       let appendOps = [PatchFileOp "notes.md" "tail\n" AppendFile Template modName]           prependOps = [PatchFileOp "notes.md" "head\n" PrependFile Template modName]-      ((fst (runExecFS emptyFS appendOps) Map.! "notes.md") ^. #additiveOnly) `shouldBe` False-      ((fst (runExecFS emptyFS prependOps) Map.! "notes.md") ^. #additiveOnly) `shouldBe` False+      ((fst (runExecFS emptyFS appendOps) Map.! "notes.md") ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure+      ((fst (runExecFS emptyFS prependOps) Map.! "notes.md") ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure      it "does not record a whole-file write as additive-only" $ do       let ops = [WriteFileOp "README.md" "# Title\n" Template]           (records, _) = runExecFS emptyFS ops-      ((records Map.! "README.md") ^. #additiveOnly) `shouldBe` False+      ((records Map.! "README.md") ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure      it "does not record a copied file as additive-only" $ do-      let initial = PureFS (Map.singleton "/source/file.txt" "copied") mempty+      let initial = PureFS (Map.singleton "/source/file.txt" "copied") mempty Map.empty           ops = [CopyFileOp "/source/file.txt" "dest.txt"]           (records, _) = runExecFS initial ops-      ((records Map.! "dest.txt") ^. #additiveOnly) `shouldBe` False+      ((records Map.! "dest.txt") ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure      it "folds the flag across every operation targeting one destination" $ do       -- A path this module both writes and patches is not additive-only, and@@ -214,7 +214,7 @@               PatchFileOp ".gitignore" "/result\n" AppendLineIfAbsent Template modName             ]           (records, _) = runExecFS emptyFS ops-      ((records Map.! ".gitignore") ^. #additiveOnly) `shouldBe` False+      ((records Map.! ".gitignore") ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure      it "keeps one destination's write mode out of another's record" $ do       let ops =@@ -222,8 +222,8 @@               PatchFileOp ".gitignore" "/result\n" AppendLineIfAbsent Template modName             ]           (records, _) = runExecFS emptyFS ops-      ((records Map.! "README.md") ^. #additiveOnly) `shouldBe` False-      ((records Map.! ".gitignore") ^. #additiveOnly) `shouldBe` True+      ((records Map.! "README.md") ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure+      ((records Map.! ".gitignore") ^. #sharedWriteMode) `shouldBe` SharedWriteAdditiveOnly    describe "dryRunPlan" $ do     it "formats WriteFileOp" $ do
test/Seihou/Engine/MigrateSpec.hs view
@@ -58,11 +58,11 @@ mkManifest entries =   (emptyManifest fixedTime)     & #modules .~ [AppliedModule {name = modName, parentVars = emptyParentVars, origin = LocalOrigin "demo", moduleVersion = Just "1.0.0", appliedAt = fixedTime, removal = Nothing}]-    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, additiveOnly = False}) | (path, content) <- entries]+    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, sharedWriteMode = SharedWriteUnknown}) | (path, content) <- entries]  -- | Build an in-memory filesystem from (path, content) pairs. mkFS :: [(FilePath, Text)] -> PureFS-mkFS entries = PureFS (Map.fromList entries) Set.empty+mkFS entries = PureFS (Map.fromList entries) Set.empty Map.empty  -- | Single-step plan wrapping the supplied ops. chain1 :: Text -> Text -> [MigrationOp] -> MigrationPlan
test/Seihou/Engine/PlanSpec.hs view
@@ -60,7 +60,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -82,7 +82,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "hello.tpl" "hello.txt" Nothing Nothing],+                  steps = [Step Template "hello.tpl" "hello.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -104,7 +104,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" (Just (ExprLit False)) Nothing],+                  steps = [Step Copy "data.txt" "data.txt" (Just (ExprLit False)) Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -126,7 +126,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" (Just (ExprLit True)) Nothing],+                  steps = [Step Copy "data.txt" "data.txt" (Just (ExprLit True)) Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -148,7 +148,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "LICENSE" "LICENSE" (Just (ExprIsSet "license")) Nothing],+                  steps = [Step Copy "LICENSE" "LICENSE" (Just (ExprIsSet "license")) Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -177,7 +177,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "pkg.cabal.tpl" "{{name}}.cabal" Nothing Nothing],+                  steps = [Step Template "pkg.cabal.tpl" "{{name}}.cabal" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -199,7 +199,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "README.md.tpl" "{{project.name}}/README.md" Nothing Nothing],+                  steps = [Step Template "README.md.tpl" "{{project.name}}/README.md" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -224,7 +224,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "README.md.tpl" "{{project.name}}/README.md" Nothing Nothing],+                  steps = [Step Template "README.md.tpl" "{{project.name}}/README.md" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -249,7 +249,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing],+                  steps = [Step Template "README.md.tpl" "docs/README.v2.md" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -271,7 +271,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "Lib.hs.tpl" "src/Lib.hs" Nothing Nothing],+                  steps = [Step Template "Lib.hs.tpl" "src/Lib.hs" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -295,8 +295,8 @@                   exports = [],                   prompts = [],                   steps =-                    [ Step Template "A.hs.tpl" "src/A.hs" Nothing Nothing,-                      Step Template "B.hs.tpl" "src/B.hs" Nothing Nothing+                    [ Step Template "A.hs.tpl" "src/A.hs" Nothing Nothing Managed,+                      Step Template "B.hs.tpl" "src/B.hs" Nothing Nothing Managed                     ],                   commands = [],                   dependencies = [],@@ -324,7 +324,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "hello.tpl" "hello.txt" Nothing Nothing],+                  steps = [Step Template "hello.tpl" "hello.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -348,7 +348,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step DhallText "greeting.dhall" "greeting.txt" Nothing Nothing],+                  steps = [Step DhallText "greeting.dhall" "greeting.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -374,7 +374,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step DhallText "pkg.dhall" "pkg.txt" Nothing Nothing],+                  steps = [Step DhallText "pkg.dhall" "pkg.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -396,7 +396,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step DhallText "bad.dhall" "out.txt" Nothing Nothing],+                  steps = [Step DhallText "bad.dhall" "out.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -420,7 +420,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Structured "data.json.gen" "data.json" Nothing Nothing],+                  steps = [Step Structured "data.json.gen" "data.json" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -451,7 +451,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Structured "config.yaml.gen" "config.yaml" Nothing Nothing],+                  steps = [Step Structured "config.yaml.gen" "config.yaml" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -483,7 +483,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Structured "bad.gen" "out.json" Nothing Nothing],+                  steps = [Step Structured "bad.gen" "out.json" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -507,7 +507,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Structured "data.gen" "output.txt" Nothing Nothing],+                  steps = [Step Structured "data.gen" "output.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -572,7 +572,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "section.tpl" "README.md" Nothing (Just AppendFile)],+                  steps = [Step Template "section.tpl" "README.md" Nothing (Just AppendFile) Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -595,7 +595,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "section.tpl" "README.md" Nothing (Just AppendSection)],+                  steps = [Step Template "section.tpl" "README.md" Nothing (Just AppendSection) Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -619,7 +619,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "conditional.tpl" "out.txt" Nothing Nothing],+                  steps = [Step Template "conditional.tpl" "out.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -643,7 +643,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "conditional.tpl" "out.txt" Nothing Nothing],+                  steps = [Step Template "conditional.tpl" "out.txt" Nothing Nothing Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -667,7 +667,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Template "patch.tpl" "README.md" Nothing (Just AppendFile)],+                  steps = [Step Template "patch.tpl" "README.md" Nothing (Just AppendFile) Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -691,7 +691,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "header.txt" "out.txt" Nothing (Just PrependFile)],+                  steps = [Step Copy "header.txt" "out.txt" Nothing (Just PrependFile) Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -714,7 +714,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Structured "data.gen" "data.json" Nothing (Just AppendFile)],+                  steps = [Step Structured "data.gen" "data.json" Nothing (Just AppendFile) Managed],                   commands = [],                   dependencies = [],                   removal = Nothing,@@ -738,7 +738,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "echo hello" Nothing Nothing],                   dependencies = [],                   removal = Nothing,@@ -791,7 +791,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "echo skip" Nothing (Just (ExprLit False))],                   dependencies = [],                   removal = Nothing,@@ -815,7 +815,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "echo yes" Nothing (Just (ExprIsSet "name"))],                   dependencies = [],                   removal = Nothing,@@ -839,7 +839,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "echo post" Nothing Nothing],                   dependencies = [],                   removal = Nothing,@@ -872,7 +872,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "npm install" (Just "subdir") Nothing],                   dependencies = [],                   removal = Nothing,@@ -897,7 +897,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "echo {{name}}" Nothing Nothing],                   dependencies = [],                   removal = Nothing,@@ -922,7 +922,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "cabal build" (Just "{{name}}") Nothing],                   dependencies = [],                   removal = Nothing,@@ -972,7 +972,7 @@                   vars = [],                   exports = [],                   prompts = [],-                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing],+                  steps = [Step Copy "data.txt" "data.txt" Nothing Nothing Managed],                   commands = [Command "echo {{missing}}" Nothing Nothing],                   dependencies = [],                   removal = Nothing,
test/Seihou/Engine/ReconcileSpec.hs view
@@ -352,6 +352,8 @@       applications = manifest ^. #applications,       recipe = manifest ^. #recipe,       blueprint = manifest ^. #blueprint,+      seeds = manifest ^. #seeds,+      checkoutOutputs = manifest ^. #checkoutOutputs,       blueprintMigrations = manifest ^. #blueprintMigrations     } @@ -364,7 +366,7 @@       generatedAt = fixedTime,       baseline = baseline,       applicationIds = Set.fromList owners',-      additiveOnly = False+      sharedWriteMode = SharedWriteUnknown     }  -- | A record for a path every owner reaches through an additive patch.@@ -377,7 +379,7 @@       generatedAt = fixedTime,       baseline = baseline,       applicationIds = Set.fromList owners',-      additiveOnly = True+      sharedWriteMode = SharedWriteAdditiveOnly     }  owners :: FilePath -> [ApplicationId] -> Map.Map FilePath DesiredFileOwner
test/Seihou/Engine/RemoveSpec.hs view
@@ -36,18 +36,18 @@ mkManifest isRemovable fileContents =   (emptyManifest fixedTime)     & #modules .~ [AppliedModule {name = modName, parentVars = emptyParentVars, origin = LocalOrigin "test-module", moduleVersion = Nothing, appliedAt = fixedTime, removal = if isRemovable then Just (Removal [] []) else Nothing}]-    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, additiveOnly = False}) | (path, content) <- fileContents]+    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, sharedWriteMode = SharedWriteUnknown}) | (path, content) <- fileContents]  -- | Helper: create a manifest with a specific removal spec. mkManifestWithRemoval :: Removal -> [(FilePath, Text)] -> Manifest mkManifestWithRemoval removal fileContents =   (emptyManifest fixedTime)     & #modules .~ [AppliedModule {name = modName, parentVars = emptyParentVars, origin = LocalOrigin "test-module", moduleVersion = Nothing, appliedAt = fixedTime, removal = Just removal}]-    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, additiveOnly = False}) | (path, content) <- fileContents]+    & #files .~ Map.fromList [(path, FileRecord {hash = hashContent content, moduleName = modName, strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, sharedWriteMode = SharedWriteUnknown}) | (path, content) <- fileContents]  -- | Helper: create a PureFS with files. mkFS :: [(FilePath, Text)] -> PureFS-mkFS fileContents = PureFS (Map.fromList fileContents) Set.empty+mkFS fileContents = PureFS (Map.fromList fileContents) Set.empty Map.empty  -- | Run computeRemovalPlan in the pure filesystem. runPlan :: PureFS -> Manifest -> ModuleName -> Either RemovalError RemovalPlan@@ -158,7 +158,7 @@      it "preserves files from other modules in manifest" $ do       let base = mkManifest True [("mine.txt", "mine")]-          otherRec = FileRecord (hashContent "other") otherMod Template fixedTime Nothing mempty False+          otherRec = FileRecord (hashContent "other") otherMod Template fixedTime Nothing mempty SharedWriteUnknown           manifest =             Manifest               { version = base ^. #version,@@ -169,6 +169,8 @@                 applications = base ^. #applications,                 recipe = base ^. #recipe,                 blueprint = base ^. #blueprint,+                seeds = base ^. #seeds,+                checkoutOutputs = base ^. #checkoutOutputs,                 blueprintMigrations = base ^. #blueprintMigrations               }           fs = mkFS [("mine.txt", "mine"), ("other.txt", "other")]@@ -341,7 +343,7 @@      it "preserves other modules' files in manifest" $ do       let base = mkManifest True [("mine.txt", "mine")]-          otherRec = FileRecord (hashContent "other") otherMod Template fixedTime Nothing mempty False+          otherRec = FileRecord (hashContent "other") otherMod Template fixedTime Nothing mempty SharedWriteUnknown           manifest =             Manifest               { version = base ^. #version,@@ -352,6 +354,8 @@                 applications = base ^. #applications,                 recipe = base ^. #recipe,                 blueprint = base ^. #blueprint,+                seeds = base ^. #seeds,+                checkoutOutputs = base ^. #checkoutOutputs,                 blueprintMigrations = base ^. #blueprintMigrations               }           fs = mkFS [("mine.txt", "mine"), ("other.txt", "other")]
test/Seihou/Engine/UpdateTransactionSpec.hs view
@@ -3,6 +3,8 @@ import Control.Exception (throwIO) import Control.Lens ((&), (.~), (^.)) import Control.Monad (unless, when)+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Lazy qualified as LBS import Data.Foldable (traverse_) import Data.Generics.Labels ()@@ -34,6 +36,44 @@  spec :: Spec spec = do+  describe "literal symlink journal snapshots" $ do+    it "restores a dangling target without traversing it" $ withSystemTempDirectory "checkout-journal" $ \root -> do+      Directory.createDirectoryLink "missing-old-target" (root </> "local-link")+      Right transaction <- beginUpdateTransaction root (Set.singleton "local-link")+      Directory.removeDirectoryLink (root </> "local-link")+      Directory.createDirectoryLink "missing-new-target" (root </> "local-link")+      restored <- recoverIncompleteTransactions root+      restored `shouldBe` [Right ()]+      Directory.getSymbolicLinkTarget (root </> "local-link") `shouldReturn` "missing-old-target"+    it "retains the version-1 regular-file recovery contract" $ withSystemTempDirectory "legacy-journal" $ \root -> do+      TIO.writeFile (root </> "managed.txt") "before"+      Right transaction <- beginUpdateTransaction root (Set.singleton "managed.txt")+      let journal = transaction ^. #transactionDirectory </> "journal.json"+      Right (Aeson.Object object) <- Aeson.eitherDecode <$> LBS.readFile journal+      let stripEntry (Aeson.Object fields) = Aeson.Object (KeyMap.delete "kind" (KeyMap.delete "target" fields))+          stripEntry other = other+          legacy = KeyMap.mapWithKey (\key value -> case (key, value) of ("version", _) -> Aeson.Number 1; ("entries", Aeson.Array values) -> Aeson.Array (fmap stripEntry values); _ -> value) object+      LBS.writeFile journal (Aeson.encode (Aeson.Object legacy))+      TIO.writeFile (root </> "managed.txt") "after"+      recoverIncompleteTransactions root `shouldReturn` [Right ()]+      TIO.readFile (root </> "managed.txt") `shouldReturn` "before"+    it "rejects an unknown version-2 kind before recovery writes" $ withSystemTempDirectory "unknown-journal" $ \root -> do+      TIO.writeFile (root </> "managed.txt") "before"+      Right transaction <- beginUpdateTransaction root (Set.singleton "managed.txt")+      let journal = transaction ^. #transactionDirectory </> "journal.json"+      bytes <- TIO.readFile journal+      TIO.writeFile journal (T.replace "\"kind\":\"file\"" "\"kind\":\"unknown\"" bytes)+      TIO.writeFile (root </> "managed.txt") "after"+      restored <- rollbackUpdateTransaction transaction+      restored `shouldSatisfy` \case Left TransactionJournalMalformed {} -> True; _ -> False+      TIO.readFile (root </> "managed.txt") `shouldReturn` "after"+    it "removes a newly created dangling link when restoring an absent snapshot" $ withSystemTempDirectory "checkout-journal" $ \root -> do+      Right transaction <- beginUpdateTransaction root (Set.singleton "local-link")+      Directory.createDirectoryLink "missing-target" (root </> "local-link")+      restored <- rollbackUpdateTransaction transaction+      restored `shouldBe` Right ()+      Directory.listDirectory root `shouldReturn` [".seihou"]+   describe "applyReconciliation" $ do     it "writes resolved files and updates baselines, hashes, ownership, and orphans" $       withSystemTempDirectory "seihou-update-transaction" $ \projectRoot -> do@@ -191,7 +231,7 @@     -- A targeted update plans only the applications it selected, so for a     -- co-owned path the reconciliation result describes a subset of the     -- owners. Everything belonging to an unselected owner must survive.-    let coOwned additive =+    let coOwned mode =           ( ( fileRecord "/dist\n" (Just (baselineRefForContent "/dist\n")) [appA, appB]                 & #moduleName                   .~ "co-owner"@@ -199,8 +239,8 @@               & #strategy                 .~ DhallText           )-            & #additiveOnly-              .~ additive+            & #sharedWriteMode+              .~ mode         alphaOnly additive =           (desiredFile ".gitignore" "/dist\n/result\n" [appA] & #moduleName .~ "alpha")             & #additiveOnly@@ -223,38 +263,42 @@             pure ((candidate ^. #files) Map.! ".gitignore")      it "keeps an unselected co-owner in the record" $ do-      record <- runPartial (Set.singleton appA) (coOwned True) (alphaOnly True)+      record <- runPartial (Set.singleton appA) (coOwned SharedWriteAdditiveOnly) (alphaOnly True)       (record ^. #applicationIds) `shouldBe` Set.fromList [appA, appB]      it "keeps the prior attribution when an unselected co-owner survives" $ do-      record <- runPartial (Set.singleton appA) (coOwned True) (alphaOnly True)+      record <- runPartial (Set.singleton appA) (coOwned SharedWriteAdditiveOnly) (alphaOnly True)       (record ^. #moduleName) `shouldBe` "co-owner"       (record ^. #strategy) `shouldBe` DhallText -    it "keeps additiveOnly true when both the prior record and the candidate agree" $ do-      record <- runPartial (Set.singleton appA) (coOwned True) (alphaOnly True)-      (record ^. #additiveOnly) `shouldBe` True+    it "keeps additive-only when both the prior record and the candidate agree" $ do+      record <- runPartial (Set.singleton appA) (coOwned SharedWriteAdditiveOnly) (alphaOnly True)+      (record ^. #sharedWriteMode) `shouldBe` SharedWriteAdditiveOnly -    it "cannot strengthen additiveOnly from a partial update" $ do-      -- The prior False may have been set because the unselected co-owner-      -- writes the whole file. This run has no evidence about that owner, so-      -- flipping the flag open here would let a later update through unsafely.-      record <- runPartial (Set.singleton appA) (coOwned False) (alphaOnly True)-      (record ^. #additiveOnly) `shouldBe` False+    it "cannot strengthen a closure requirement from a partial update" $ do+      -- The unselected co-owner may write the whole file. This run has no+      -- evidence about that owner, so opening the gate here would let a later+      -- update through unsafely.+      record <- runPartial (Set.singleton appA) (coOwned SharedWriteRequiresOwnershipClosure) (alphaOnly True)+      (record ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure -    it "weakens additiveOnly when the candidate is no longer additive" $ do-      record <- runPartial (Set.singleton appA) (coOwned True) (alphaOnly False)-      (record ^. #additiveOnly) `shouldBe` False+    it "keeps unknown evidence unknown from a partial update" $ do+      record <- runPartial (Set.singleton appA) (coOwned SharedWriteUnknown) (alphaOnly True)+      (record ^. #sharedWriteMode) `shouldBe` SharedWriteUnknown +    it "records a closure requirement when the candidate is no longer additive" $ do+      record <- runPartial (Set.singleton appA) (coOwned SharedWriteAdditiveOnly) (alphaOnly False)+      (record ^. #sharedWriteMode) `shouldBe` SharedWriteRequiresOwnershipClosure+     it "re-credits the path and trusts the candidate once every owner is selected" $ do       -- Subtracting the selection rather than the desired set matters here:       -- appA stopped writing the path but was part of this update, so it       -- genuinely loses ownership and the candidate's own answer stands.-      record <- runPartial (Set.fromList [appA, appB]) (coOwned False) (alphaOnly True)+      record <- runPartial (Set.fromList [appA, appB]) (coOwned SharedWriteUnknown) (alphaOnly True)       (record ^. #applicationIds) `shouldBe` Set.singleton appA       (record ^. #moduleName) `shouldBe` "alpha"       (record ^. #strategy) `shouldBe` Template-      (record ^. #additiveOnly) `shouldBe` True+      (record ^. #sharedWriteMode) `shouldBe` SharedWriteAdditiveOnly    describe "rollback and recovery" $ do     it "rolls every earlier mutation back after an injected failure" $@@ -350,6 +394,8 @@                   applications = candidate ^. #applications,                   recipe = candidate ^. #recipe,                   blueprint = candidate ^. #blueprint,+                  seeds = candidate ^. #seeds,+                  checkoutOutputs = candidate ^. #checkoutOutputs,                   blueprintMigrations = candidate ^. #blueprintMigrations                 }         setUpdateTransactionExpectedManifest transaction finalManifest `shouldReturn` Right ()@@ -446,6 +492,8 @@           applications = manifest ^. #applications,           recipe = manifest ^. #recipe,           blueprint = manifest ^. #blueprint,+          seeds = manifest ^. #seeds,+          checkoutOutputs = manifest ^. #checkoutOutputs,           blueprintMigrations = manifest ^. #blueprintMigrations         } @@ -458,7 +506,7 @@       generatedAt = fixedTime,       baseline = baseline,       applicationIds = Set.fromList owners,-      additiveOnly = False+      sharedWriteMode = SharedWriteUnknown     }  desiredFile :: FilePath -> Text -> [ApplicationId] -> DesiredFile
test/Seihou/Engine/ValidateSpec.hs view
@@ -40,7 +40,8 @@               src = "README.md.tpl",               dest = "README.md",               condition = Nothing,-              patch = Nothing+              patch = Nothing,+              lifecycle = Managed             }         ],       commands = [],@@ -62,7 +63,7 @@         ],       exports = [VarExport {var = "nonexistent", alias = Nothing}],       prompts = [Prompt {var = "undeclared", text = "?", condition = Nothing, choices = Nothing}],-      steps = [Step Template "missing.tpl" "/absolute/path" Nothing Nothing],+      steps = [Step Template "missing.tpl" "/absolute/path" Nothing Nothing Managed],       commands = [],       dependencies = [],       removal = Nothing,@@ -222,8 +223,8 @@         writeFile (tmpDir </> "files" </> "b.tpl") "stub"         let m =               withSteps-                [ Step Template "a.tpl" "out.txt" Nothing Nothing,-                  Step Template "b.tpl" "out.txt" Nothing Nothing+                [ Step Template "a.tpl" "out.txt" Nothing Nothing Managed,+                  Step Template "b.tpl" "out.txt" Nothing Nothing Managed                 ]                 goodModule         report <- buildReport True tmpDir m@@ -236,8 +237,8 @@         writeFile (tmpDir </> "files" </> "b.tpl") "stub"         let m =               withSteps-                [ Step Template "a.tpl" "out.txt" Nothing Nothing,-                  Step Template "b.tpl" "out.txt" Nothing (Just AppendFile)+                [ Step Template "a.tpl" "out.txt" Nothing Nothing Managed,+                  Step Template "b.tpl" "out.txt" Nothing (Just AppendFile) Managed                 ]                 goodModule         report <- buildReport True tmpDir m@@ -281,7 +282,7 @@           ]         stepWithCondition cond =           withSteps-            [Step Template "README.md.tpl" "README.md" cond Nothing]+            [Step Template "README.md.tpl" "README.md" cond Nothing Managed]             (withVars baseVars goodModule)      it "flags a bool variable compared against a string literal in a when clause" $ do
test/Seihou/Integration/ExecutionSpec.hs view
@@ -68,6 +68,8 @@           applications = base ^. #applications,           recipe = base ^. #recipe,           blueprint = base ^. #blueprint,+          seeds = base ^. #seeds,+          checkoutOutputs = base ^. #checkoutOutputs,           blueprintMigrations = base ^. #blueprintMigrations         } @@ -170,7 +172,7 @@                 executePlan "" ops Map.empty modName fixedTime           manifest = manifestWithFiles fixedTime records           -- Simulate user editing README.md-          fs2 = PureFS (Map.insert "README.md" "user edit" (fs1 ^. #files)) (fs1 ^. #dirs)+          fs2 = PureFS (Map.insert "README.md" "user edit" (fs1 ^. #files)) (fs1 ^. #dirs) Map.empty           -- Compute diff → should detect conflict           (diff, _) =             runPureEff $
test/Seihou/Integration/GenerationSpec.hs view
@@ -98,7 +98,7 @@           -- Provide all vars but use an empty var map for IsSet evaluation.           -- The real scenario: license has a default so it's always set.           -- To test the conditional, we use a stripped-down module with only the LICENSE step.-          let licenseStep = Step Copy "LICENSE" "LICENSE" (Just (ExprIsSet "license")) Nothing+          let licenseStep = Step Copy "LICENSE" "LICENSE" (Just (ExprIsSet "license")) Nothing Managed               smallModule = modul & #steps .~ [licenseStep]               vars = Map.empty -- no license variable set           planResult <- compilePlan (fixtures </> "haskell-base") smallModule vars
test/Seihou/Manifest/TypesSpec.hs view
@@ -50,7 +50,7 @@ -- | Helper to set modules on a Manifest without ambiguous record update. withManifestModules :: [AppliedModule] -> Manifest -> Manifest withManifestModules mods m =-  Manifest (m ^. #version) (m ^. #genAt) mods (m ^. #vars) (m ^. #files) (m ^. #applications) (m ^. #recipe) (m ^. #blueprint) (m ^. #blueprintMigrations)+  Manifest (m ^. #version) (m ^. #genAt) mods (m ^. #vars) (m ^. #files) (m ^. #seeds) (m ^. #checkoutOutputs) (m ^. #applications) (m ^. #recipe) (m ^. #blueprint) (m ^. #blueprintMigrations)  -- | Every string that appears anywhere inside a value keyed @origin@ or -- @targetOrigin@, at any depth.@@ -122,6 +122,8 @@ manifestWithEveryStringPosition :: Manifest 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)     & #modules       .~ [ AppliedModule              { name = ModuleName "haskell-base",@@ -148,7 +150,7 @@             fixedTime             (Just (BaselineRef (hashContent "flake")))             (Set.singleton (ApplicationId "app"))-            False+            SharedWriteRequiresOwnershipClosure         )     & #applications       %~ map (withCommandReceipts (Map.singleton receiptFingerprint receipt))@@ -265,7 +267,7 @@     it "creates a manifest with the current version" $ do       let m = emptyManifest fixedTime       (m ^. #version) `shouldBe` currentManifestVersion-      (m ^. #version) `shouldBe` 6+      (m ^. #version) `shouldBe` currentManifestVersion      it "creates a manifest with no modules, vars, or files" $ do       let m = emptyManifest fixedTime@@ -344,6 +346,8 @@                 applications = base ^. #applications,                 recipe = Nothing,                 blueprint = Nothing,+                seeds = Map.empty,+                checkoutOutputs = Map.empty,                 blueprintMigrations = []               }       manifestFromJSON (manifestToJSON m) `shouldBe` Right m@@ -352,7 +356,7 @@       let m :: Manifest           m =             ( (emptyManifest fixedTime)-                & #files .~ Map.fromList [("README.md", FileRecord {hash = SHA256 "abc123", moduleName = ModuleName "haskell-base", strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, additiveOnly = False}), ("my-app.cabal", FileRecord {hash = SHA256 "def456", moduleName = ModuleName "haskell-base", strategy = DhallText, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, additiveOnly = False})]+                & #files .~ Map.fromList [("README.md", FileRecord {hash = SHA256 "abc123", moduleName = ModuleName "haskell-base", strategy = Template, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, sharedWriteMode = SharedWriteUnknown}), ("my-app.cabal", FileRecord {hash = SHA256 "def456", moduleName = ModuleName "haskell-base", strategy = DhallText, generatedAt = fixedTime, baseline = Nothing, applicationIds = mempty, sharedWriteMode = SharedWriteUnknown})]             )       manifestFromJSON (manifestToJSON m) `shouldBe` Right m @@ -373,15 +377,17 @@                 files =                   Map.fromList                     [ ( "README.md",-                        FileRecord (SHA256 "aaa") (ModuleName "haskell-base") Template fixedTime Nothing mempty False+                        FileRecord (SHA256 "aaa") (ModuleName "haskell-base") Template fixedTime Nothing mempty SharedWriteUnknown                       ),                       ( "LICENSE",-                        FileRecord (SHA256 "bbb") (ModuleName "haskell-base") Copy fixedTime Nothing mempty False+                        FileRecord (SHA256 "bbb") (ModuleName "haskell-base") Copy fixedTime Nothing mempty SharedWriteUnknown                       )                     ],                 applications = [],                 recipe = Nothing,                 blueprint = Nothing,+                seeds = Map.empty,+                checkoutOutputs = Map.empty,                 blueprintMigrations = []               }       manifestFromJSON (manifestToJSON m) `shouldBe` Right m@@ -389,7 +395,7 @@     it "roundtrips all strategy types" $ do       let strategies = [Copy, Template, DhallText, Structured]           makeRecord s =-            FileRecord (SHA256 "hash") (ModuleName "mod") s fixedTime Nothing mempty False+            FileRecord (SHA256 "hash") (ModuleName "mod") s fixedTime Nothing mempty SharedWriteUnknown           m :: Manifest           m =             ( (emptyManifest fixedTime)@@ -503,7 +509,7 @@                 generatedAt = fixedTime,                 baseline = Just (BaselineRef (hashContent "generated baseline")),                 applicationIds = Set.fromList [appId1, appId2],-                additiveOnly = False+                sharedWriteMode = SharedWriteUnknown               }           manifest =             ( (emptyManifest fixedTime)@@ -664,7 +670,7 @@                 commandReceipts = Map.empty,                 appliedAt = fixedTime               }-          fileRecord = FileRecord (SHA256 "hash") "base" Template fixedTime Nothing mempty False+          fileRecord = FileRecord (SHA256 "hash") "base" Template fixedTime Nothing mempty SharedWriteUnknown           recipe = AppliedRecipe "recipe" (LocalOrigin "recipe") (Just "1.0.0") fixedTime           normalBlueprint = AppliedBlueprint "payments" (LocalOrigin "payments") (Just "0.4.0") fixedTime [] False Nothing Nothing           seed =@@ -707,8 +713,8 @@         Right _ -> expectationFailure "a version-1 manifest should not decode directly"         Left err -> err `shouldSatisfy` isInfixOf "seihou manifest upgrade" -  describe "additive-only file records" $ do-    let additiveRecord additive =+  describe "shared-write evidence" $ do+    let modeRecord mode =           FileRecord             { hash = SHA256 "aaa",               moduleName = ModuleName "nix-haskell-flake",@@ -716,46 +722,105 @@               generatedAt = fixedTime,               baseline = Nothing,               applicationIds = Set.fromList [ApplicationId "app-one", ApplicationId "app-two"],-              additiveOnly = additive+              sharedWriteMode = mode             }-        manifestWith additive =-          (emptyManifest fixedTime) & #files .~ Map.singleton ".gitignore" (additiveRecord additive)-        recordKeys manifest = case Aeson.decode (manifestToJSON manifest) of+        manifestWith mode =+          (emptyManifest fixedTime) & #files .~ Map.singleton ".gitignore" (modeRecord mode)+        recordObject manifest = case Aeson.decode (manifestToJSON manifest) of           Just (Aeson.Object top) -> case KeyMap.lookup "files" top of             Just (Aeson.Object files) -> case KeyMap.lookup ".gitignore" files of-              Just (Aeson.Object record) -> map Key.toText (KeyMap.keys record)-              _ -> []-            _ -> []-          _ -> []+              Just (Aeson.Object record) -> record+              _ -> KeyMap.empty+            _ -> KeyMap.empty+          _ -> KeyMap.empty+        schemaJson :: Int -> String -> String+        schemaJson version recordTail =+          "{\"version\":"+            <> show version+            <> ",\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[]"+            <> ",\"variables\":{},\"applications\":[],\"files\":{\".gitignore\":{\"hash\":\"aaa\""+            <> ",\"module\":\"nix-haskell-flake\",\"strategy\":\"template\""+            <> ",\"generatedAt\":\"2026-03-01T10:30:00Z\""+            <> recordTail+            <> "}}}"+        schema6Json = schemaJson 6+        schema7Json = schemaJson 7+        decodedMode json = case manifestFromJSON (LBS8.pack json) of+          Left err -> Left err+          Right decoded -> maybe (Left "no .gitignore record") (Right . (^. #sharedWriteMode)) (Map.lookup ".gitignore" (decoded ^. #files)) -    it "roundtrips a record whose additiveOnly is true" $ do-      manifestFromJSON (manifestToJSON (manifestWith True)) `shouldBe` Right (manifestWith True)+    forM_ [minBound .. maxBound] $ \mode ->+      it ("roundtrips a schema-7 record whose mode is " <> show mode) $+        manifestFromJSON (manifestToJSON (manifestWith mode)) `shouldBe` Right (manifestWith mode) -    it "roundtrips a record whose additiveOnly is false" $ do-      manifestFromJSON (manifestToJSON (manifestWith False)) `shouldBe` Right (manifestWith False)+    it "always emits sharedWriteMode at schema 7, including unknown" $ do+      KeyMap.lookup "sharedWriteMode" (recordObject (manifestWith SharedWriteUnknown))+        `shouldBe` Just (Aeson.String "unknown")+      KeyMap.lookup "sharedWriteMode" (recordObject (manifestWith SharedWriteAdditiveOnly))+        `shouldBe` Just (Aeson.String "additive-only")+      KeyMap.lookup "sharedWriteMode" (recordObject (manifestWith SharedWriteRequiresOwnershipClosure))+        `shouldBe` Just (Aeson.String "requires-ownership-closure")+      KeyMap.member "additiveOnly" (recordObject (manifestWith SharedWriteAdditiveOnly)) `shouldBe` False -    it "emits the additiveOnly key only when the flag is true" $ do-      recordKeys (manifestWith True) `shouldContain` ["additiveOnly"]-      recordKeys (manifestWith False) `shouldNotContain` ["additiveOnly"]+    it "decodes a schema-6 additiveOnly: true as certified additive-only" $+      decodedMode (schema6Json ",\"additiveOnly\":true") `shouldBe` Right SharedWriteAdditiveOnly -    it "decodes a record with no additiveOnly key as not additive-only" $ do-      -- A manifest written before the field existed must keep every shared-      -- path under the ownership closure, so the absent key must fail closed.-      let json =-            "{\"version\":6,\"generatedAt\":\"2026-03-01T10:30:00Z\",\"modules\":[]"-              <> ",\"variables\":{},\"applications\":[],\"files\":{\".gitignore\":{\"hash\":\"aaa\""-              <> ",\"module\":\"nix-haskell-flake\",\"strategy\":\"template\""-              <> ",\"generatedAt\":\"2026-03-01T10:30:00Z\"}}}"-      case manifestFromJSON json of-        Left err -> expectationFailure ("expected the manifest to decode, got: " <> err)-        Right decoded -> case Map.lookup ".gitignore" (decoded ^. #files) of-          Nothing -> expectationFailure "expected a .gitignore record"-          Just decodedRecord -> (decodedRecord ^. #additiveOnly) `shouldBe` False+    it "decodes a schema-6 record with no additiveOnly key as unknown, not as closure-required" $+      -- The version-6 encoder omitted false, so absence proves nothing.+      decodedMode (schema6Json "") `shouldBe` Right SharedWriteUnknown +    it "decodes a schema-6 additiveOnly: false as unknown" $+      decodedMode (schema6Json ",\"additiveOnly\":false") `shouldBe` Right SharedWriteUnknown++    it "keeps the decoded schema-6 version so capability checks see what the file proves" $+      case manifestFromJSON (LBS8.pack (schema6Json "")) of+        Left err -> expectationFailure err+        Right decoded -> do+          (decoded ^. #version) `shouldBe` ManifestSchemaVersion 6+          manifestSupports TargetedAdditiveSharedPathUpdate decoded `shouldBe` False++    it "re-encodes a schema-6 manifest in the schema-6 representation" $ do+      let asSchema6 mode = manifestWith mode & #version .~ ManifestSchemaVersion 6+      KeyMap.lookup "additiveOnly" (recordObject (asSchema6 SharedWriteAdditiveOnly)) `shouldBe` Just (Aeson.Bool True)+      KeyMap.member "sharedWriteMode" (recordObject (asSchema6 SharedWriteAdditiveOnly)) `shouldBe` False+      -- Schema 6 cannot say "closure required"; it fails closed to unknown.+      KeyMap.member "additiveOnly" (recordObject (asSchema6 SharedWriteRequiresOwnershipClosure)) `shouldBe` False++    it "rejects a schema-7 record without sharedWriteMode and names the record and key" $+      case manifestFromJSON (LBS8.pack (schema7Json "")) of+        Right _ -> expectationFailure "a schema-7 record without sharedWriteMode must not decode"+        Left err -> do+          err `shouldSatisfy` isInfixOf ".gitignore"+          err `shouldSatisfy` isInfixOf "sharedWriteMode"++    it "rejects a schema-7 record with an unrecognised sharedWriteMode" $+      case manifestFromJSON (LBS8.pack (schema7Json ",\"sharedWriteMode\":\"maybe\"")) of+        Right _ -> expectationFailure "an unknown sharedWriteMode value must not decode"+        Left err -> do+          err `shouldSatisfy` isInfixOf ".gitignore"+          err `shouldSatisfy` isInfixOf "sharedWriteMode"+          err `shouldSatisfy` isInfixOf "maybe"++    it "does not read a schema-7 additiveOnly key as evidence" $+      case manifestFromJSON (LBS8.pack (schema7Json ",\"additiveOnly\":true")) of+        Right _ -> expectationFailure "schema 7 requires sharedWriteMode even when additiveOnly is present"+        Left err -> err `shouldSatisfy` isInfixOf "sharedWriteMode"++  describe "manifest capabilities" $ do+    it "requires schema 7 for targeted additive shared-path updates" $+      minimumManifestVersion TargetedAdditiveSharedPathUpdate `shouldBe` ManifestSchemaVersion 7++    it "never requires a schema newer than this build" $+      forM_ [minBound .. maxBound] $ \capability ->+        minimumManifestVersion capability `shouldSatisfy` (<= currentManifestVersion)++    it "is supported by a schema-7 manifest" $+      manifestSupports TargetedAdditiveSharedPathUpdate (emptyManifest fixedTime) `shouldBe` True+   describe "version checking" $ do     it "rejects manifests with version higher than current" $ do       let base = emptyManifest fixedTime-          m = Manifest {version = 99, genAt = base ^. #genAt, modules = base ^. #modules, vars = base ^. #vars, files = base ^. #files, applications = base ^. #applications, recipe = Nothing, blueprint = Nothing, blueprintMigrations = []}+          m = Manifest {version = ManifestSchemaVersion 99, genAt = base ^. #genAt, modules = base ^. #modules, vars = base ^. #vars, files = base ^. #files, applications = base ^. #applications, recipe = Nothing, blueprint = Nothing, seeds = Map.empty, checkoutOutputs = Map.empty, blueprintMigrations = []}           result = manifestFromJSON (manifestToJSON m)       case result of         Left err -> err `shouldContain` "newer version"
+ test/Seihou/Manifest/UpgradeSpec.hs view
@@ -0,0 +1,174 @@+module Seihou.Manifest.UpgradeSpec (tests) where++import Control.Lens ((^.))+import Control.Monad (forM_)+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Generics.Labels ()+import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Seihou.Core.Types+import Seihou.Manifest.Types+import Seihou.Manifest.Upgrade+import Test.Hspec+import Test.Tasty+import Test.Tasty.Hspec (testSpec)++tests :: IO TestTree+tests = testSpec "Seihou.Manifest.Upgrade" spec++v :: Int -> ManifestSchemaVersion+v = ManifestSchemaVersion++-- | A schema-6 document with an additive path, a path with no evidence, an+-- explicit false, and keys this build does not model at every level.+schema6Document :: Aeson.Value+schema6Document =+  Aeson.object+    [ "version" Aeson..= (6 :: Int),+      "generatedAt" Aeson..= ("2026-03-01T10:30:00Z" :: String),+      "modules" Aeson..= ([] :: [Aeson.Value]),+      "variables" Aeson..= Aeson.object [],+      "applications" Aeson..= ([] :: [Aeson.Value]),+      "producerOwned" Aeson..= Aeson.object ["keep" Aeson..= True],+      "files"+        Aeson..= Aeson.object+          [ ".gitignore" Aeson..= record [("additiveOnly", Aeson.Bool True), ("applications", Aeson.toJSON ["a" :: String, "b"])],+            "README.md" Aeson..= record [("futureKey", Aeson.String "survives")],+            "flake.nix" Aeson..= record [("additiveOnly", Aeson.Bool False)]+          ]+    ]+  where+    record extra =+      Aeson.Object+        ( KeyMap.fromList+            ( [ ("hash", Aeson.String "aaa"),+                ("module", Aeson.String "base"),+                ("strategy", Aeson.String "template"),+                ("generatedAt", Aeson.String "2026-03-01T10:30:00Z")+              ]+                <> extra+            )+        )++fileField :: String -> Aeson.Key -> Aeson.Value -> Maybe Aeson.Value+fileField path key (Aeson.Object top) = case KeyMap.lookup "files" top of+  Just (Aeson.Object files) -> case KeyMap.lookup (Key.fromString path) files of+    Just (Aeson.Object record) -> KeyMap.lookup key record+    _ -> Nothing+  _ -> Nothing+fileField _ _ _ = Nothing++spec :: Spec+spec = do+  describe "the step registry" $ do+    it "plans a contiguous path from every supported version to the current one" $+      -- ADR 0014's mechanical gap check: a schema bump without its adjacent+      -- step fails here.+      forM_ [oldestUpgradableManifestVersion ^. #unManifestSchemaVersion .. currentManifestVersion ^. #unManifestSchemaVersion] $ \source ->+        case planManifestUpgrade (v source) currentManifestVersion of+          Left err -> expectationFailure ("no path from schema " <> show source <> ": " <> show err)+          Right steps -> do+            map (^. #fromVersion) steps `shouldBe` map v [source .. currentManifestVersion ^. #unManifestSchemaVersion - 1]+            map (^. #toVersion) steps `shouldBe` map v [source + 1 .. currentManifestVersion ^. #unManifestSchemaVersion]++    it "defines exactly one step per source version" $ do+      let sources = map (^. #fromVersion) manifestUpgradeSteps+      length sources `shouldBe` Map.size (Map.fromList [(s, ()) | s <- sources])++    it "returns an empty path from a version to itself" $+      planManifestUpgrade (v 7) (v 7) `shouldBe` Right []++    it "stops at a requested intermediate target" $+      map (^. #toVersion) <$> planManifestUpgrade (v 5) (v 6) `shouldBe` Right [v 6]++    it "reports a missing adjacent step instead of jumping over it" $ do+      let gappy = filter ((/= v 6) . (^. #fromVersion)) manifestUpgradeSteps+      planUpgradeWith gappy (v 7) (v 5) (v 7) `shouldBe` Left (MissingUpgradeStep (v 6))++    it "rejects targets above current and below the source" $ do+      planManifestUpgrade (v 6) (v 99) `shouldBe` Left (TargetAboveCurrent (v 99))+      planManifestUpgrade (v 7) (v 6) `shouldBe` Left (TargetBelowSource (v 7) (v 6))+      planManifestUpgrade (v 99) (v 99) `shouldBe` Left (SourceNewerThanBinary (v 99))+      planManifestUpgrade (v 0) (v 7) `shouldBe` Left (SourceVersionUnsupported (v 0))++    it "classifies the path conversion as inference-bearing and 6 -> 7 as lossless" $ do+      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 7 -> 8 step" $ do+    it "preserves unrelated raw fields and adds seed receipts" $ do+      Right (_, upgraded) <- pure (upgradeDocumentLosslessly (v 8) schema6Document)+      documentSchemaVersion upgraded `shouldBe` Right (v 8)+      case upgraded of+        Aeson.Object fields -> do+          KeyMap.lookup "seeds" fields `shouldBe` Just (Aeson.object [])+          KeyMap.lookup "producerOwned" fields `shouldBe` Just (Aeson.object ["keep" Aeson..= True])+        _ -> expectationFailure "expected object"+      case Aeson.fromJSON upgraded :: Aeson.Result Manifest of+        Aeson.Success manifest -> manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest+        Aeson.Error err -> expectationFailure err+    it "rejects malformed seed containers before stamping" $ do+      let document = Aeson.object ["version" Aeson..= (7 :: Int), "seeds" Aeson..= False]+      upgradeDocumentLosslessly (v 8) document `shouldSatisfy` either (const True) (const False)++  describe "the 6 -> 7 step" $ do+    let upgraded = upgradeDocumentLosslessly (v 7) schema6Document++    it "maps additiveOnly: true to additive-only and absent or false to unknown" $ do+      (fileField ".gitignore" "sharedWriteMode" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "additive-only"))+      (fileField "README.md" "sharedWriteMode" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "unknown"))+      (fileField "flake.nix" "sharedWriteMode" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "unknown"))++    it "removes the schema-6 key and keeps unrelated members at every level" $ do+      (fileField ".gitignore" "additiveOnly" . snd <$> upgraded) `shouldBe` Right Nothing+      (fileField "README.md" "futureKey" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.String "survives"))+      (fileField ".gitignore" "applications" . snd <$> upgraded) `shouldBe` Right (Just (Aeson.toJSON ["a" :: String, "b"]))+      case snd <$> upgraded of+        Right (Aeson.Object top) -> KeyMap.lookup "producerOwned" top `shouldBe` Just (Aeson.object ["keep" Aeson..= True])+        other -> expectationFailure ("unexpected result: " <> show other)++    it "stamps version 7 and reports the one step it ran" $ do+      (documentSchemaVersion . snd =<< upgraded) `shouldBe` Right (v 7)+      (map (^. #fromVersion) . fst <$> upgraded) `shouldBe` Right [v 6]++    it "produces a document the typed decoder reads and round-trips" $+      case upgraded of+        Left err -> expectationFailure (show err)+        Right (_, document) -> case manifestFromJSON (Aeson.encode document) of+          Left err -> expectationFailure err+          Right manifest -> do+            (manifest ^. #version) `shouldBe` v 7+            fmap (^. #sharedWriteMode) (Map.lookup ".gitignore" (manifest ^. #files)) `shouldBe` Just SharedWriteAdditiveOnly+            fmap (^. #sharedWriteMode) (Map.lookup "README.md" (manifest ^. #files)) `shouldBe` Just SharedWriteUnknown+            manifestSupports TargetedAdditiveSharedPathUpdate manifest `shouldBe` True+            manifestFromJSON (manifestToJSON manifest) `shouldBe` Right manifest++    it "is a no-op from 7 to 7" $+      case upgraded of+        Left err -> expectationFailure (show err)+        Right (_, document) -> upgradeDocumentLosslessly (v 7) document `shouldBe` Right ([], document)++    it "rejects a malformed additiveOnly value" $ do+      let bad = Aeson.object ["version" Aeson..= (6 :: Int), "files" Aeson..= Aeson.object ["x" Aeson..= Aeson.object ["additiveOnly" Aeson..= ("yes" :: String)]]]+      case upgradeDocumentLosslessly (v 7) bad of+        Left (MalformedDocument source _) -> source `shouldBe` v 6+        other -> expectationFailure ("expected a malformed-document error, got " <> show other)++    it "refuses to run on a document at another version" $ do+      let sixToSeven = last manifestUpgradeSteps+          atFive = setDocumentSchemaVersion (v 5) schema6Document+      applyLosslessUpgradeStep sixToSeven atFive `shouldBe` Left (StepVersionMismatch sixToSeven (v 5))++  describe "lossless staging" $ do+    it "refuses to cross the inference-bearing 5 -> 6 conversion" $ do+      let atFive = setDocumentSchemaVersion (v 5) schema6Document+      case upgradeDocumentLosslessly (v 7) atFive of+        Left (StepRequiresInference s) -> (s ^. #fromVersion) `shouldBe` v 5+        other -> expectationFailure ("expected the path conversion to block, got " <> show other)++    it "names the explicit upgrade command when it refuses" $+      forM_ [s | s <- manifestUpgradeSteps, s ^. #fromVersion == v 5] $ \step5 ->+        renderManifestUpgradeError (StepRequiresInference step5) `shouldSatisfy` T.isInfixOf "seihou manifest upgrade"