packages feed

clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Internal/AdoptionValidation.hs

-- | Versioned whole-batch validation. No IO, SQL splitting or schema inference.
module ClickHouseMigrate.Internal.AdoptionValidation where

import ClickHouseMigrate.Definition qualified as Def
import ClickHouseMigrate.Internal.AdoptionTypes
import ClickHouseMigrate.Internal.Checksum qualified as Hash
import ClickHouseMigrate.Internal.LedgerCodec qualified as Codec
import ClickHouseMigrate.Internal.LedgerTypes qualified as L
import ClickHouseMigrate.Internal.Reporting qualified as Reporting
import ClickHouseMigrate.Ledger qualified as Ledger
import ClickHouseMigrate.Plan qualified as Plan
import ClickHouseMigrate.Prelude
import ClickHouseMigrate.Report qualified as R
import ClickHouseMigrate.Target (TargetKey, ledgerId)
import Data.ByteString qualified as BS
import Data.ByteString.Builder (Builder, byteString, toLazyByteString, word64BE)
import Data.ByteString.Lazy qualified as Lazy
import Data.List.NonEmpty qualified as NE
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding

require :: AdoptionError -> Bool -> Either AdoptionError ()
require failure condition = if condition then Right () else Left failure

validText :: Int -> Text -> Bool
validText bound value = not (Text.null (Text.strip value)) && Text.length value <= bound && not (Text.any (`elem` ['\0', '\n', '\r']) value)

validHash :: Text -> Bool
validHash value = Text.length value == 64 && Text.all (\c -> c >= '0' && c <= '9' || c >= 'a' && c <= 'f') value

references :: [Text] -> Either AdoptionError ()
references refs = do
  require InvalidSourceEvidence (not (null refs) && length refs <= 24)
  either (const (Left InvalidSourceEvidence)) (const (Right ())) (Ledger.auditContext "adoption evidence" "reviewed mapping" refs)

sourceChecksum :: Text -> Text -> Either AdoptionError SourceChecksum
sourceChecksum algorithm digest = do
  require UnsupportedSourceChecksum (algorithm == "sha256")
  require InvalidSourceChecksum (validHash digest)
  pure (SourceChecksum algorithm digest)

legacyRow :: Text -> Text -> Maybe SourceChecksum -> Either AdoptionError LegacyRow
legacyRow identity appliedAt checksum = do
  require InvalidSourceEvidence (validText 512 identity && validText 256 appliedAt)
  pure (LegacyRow identity appliedAt checksum)

mapping :: Text -> Text -> Text -> Text -> MappingEvidence -> Either AdoptionError AdoptionMapping
mapping row component migration checksum evidence = do
  require InvalidSourceEvidence (validText 512 row && validHash checksum)
  either (const (Left MappingDefinitionMismatch)) (const (Right ())) (Def.componentName component)
  either (const (Left MappingDefinitionMismatch)) (const (Right ())) (Def.migrationName migration)
  case evidence of
    Attested refs -> references refs
    Verified bytes refs -> require AdoptionBatchTooLarge (BS.length bytes <= 2097152) >> references refs
  pure (AdoptionMapping row component migration checksum evidence)

adoptionRequest :: TargetKey -> L.ExpectedLedgerState -> Maybe L.InitializationAuthorization -> L.QuiescenceEvidence -> L.AuditContext -> Text -> Text -> NE.NonEmpty LegacyRow -> NE.NonEmpty AdoptionMapping -> Either AdoptionError AdoptionRequest
adoptionRequest target expected authorization quiet audit source reference rows mappings = do
  require InvalidSourceEvidence (validText 512 source && validText 1000 reference && not (null (Ledger.auditReferences audit)))
  require AdoptionBatchTooLarge (NE.length rows <= 1000 && NE.length mappings <= 1000)
  let historicalBytes = sum [BS.length bytes | AdoptionMapping _ _ _ _ (Verified bytes _) <- NE.toList mappings]
      referenceBytes = sum [sum (map (BS.length . Encoding.encodeUtf8) refs) | AdoptionMapping _ _ _ _ evidence <- NE.toList mappings, refs <- [case evidence of Attested values -> values; Verified _ values -> values]]
  require AdoptionBatchTooLarge (historicalBytes <= 67108864 && referenceBytes <= 8388608)
  require AdoptionTargetMismatch (Ledger.quiescenceTarget quiet == target)
  case expected of
    L.ExpectAbsent actual -> require AdoptionTargetMismatch (actual == target) >> require AdoptionInitializationRequired (isJust authorization)
    L.ExpectPosition (L.LedgerPosition expectedLedger _ _) -> case authorization of
      Just (L.InitializationAuthorization _ authorizedLedger _) -> require AdoptionTargetMismatch (expectedLedger == authorizedLedger)
      _ -> pure ()
  case authorization of Just (L.InitializationAuthorization actual _ _) -> require AdoptionTargetMismatch (actual == target); _ -> pure ()
  pure (AdoptionRequest target expected authorization quiet audit source reference (NE.toList rows) (NE.toList mappings))

field :: ByteString -> Builder
field bytes = word64BE (fromIntegral (BS.length bytes)) <> byteString bytes

textField :: Text -> Builder
textField = field . Encoding.encodeUtf8

list :: (a -> Builder) -> [a] -> Builder
list encode values = word64BE (fromIntegral (length values)) <> foldMap encode values

hashEncoding :: ByteString -> Builder -> Text
hashEncoding domain value = Hash.digestHex (Hash.digest (Lazy.toStrict (toLazyByteString (byteString domain <> value))))

snapshotDigest :: Text -> Text -> [LegacyRow] -> Text
snapshotDigest source reference rows = hashEncoding "clickhouse-migrate/adoption-snapshot\0v1\0" (textField source <> textField reference <> list encode rows)
  where
    encode (LegacyRow identity appliedAt checksum) = textField identity <> textField appliedAt <> case checksum of Nothing -> word64BE 0; Just (SourceChecksum algorithm digest) -> word64BE 1 <> textField algorithm <> textField digest

mappingDigest :: Text -> [(Text, L.Definition, L.Evidence)] -> Text
mappingDigest snapshot entries = hashEncoding "clickhouse-migrate/adoption-mapping\0v1\0" (textField snapshot <> list encode entries)
  where
    encode (row, definition, L.Evidence level refs) = textField row <> field (Codec.encodeValue (Codec.definitionValue definition)) <> word64BE (case level of L.OperatorAttestation -> 0; L.PredecessorChecksum -> 1) <> list textField refs

buildBatch :: Plan.MigrationPlan -> AdoptionRequest -> Either AdoptionError (L.AdoptionBatch, [(L.Definition, L.Evidence)])
buildBatch plan (AdoptionRequest _ _ _ _ audit source reference rows mappings) = do
  let rowIds = [identity | LegacyRow identity _ _ <- rows]
      mappingIds = [identity | AdoptionMapping identity _ _ _ _ <- mappings]
      nativeIds = [(component, migration) | AdoptionMapping _ component migration _ _ <- mappings]
      supplied = Codec.definitions plan
  require DuplicateSourceRow (Set.size (Set.fromList rowIds) == length rowIds)
  require DuplicateMapping (Set.size (Set.fromList mappingIds) == length mappingIds && Set.size (Set.fromList nativeIds) == length nativeIds)
  require UnknownSourceRow (all (`elem` rowIds) mappingIds)
  require IncompleteMapping (Set.fromList rowIds == Set.fromList mappingIds && length mappings <= length supplied)
  require MappingOrderMismatch (rowIds == mappingIds)
  identity <- either (const (Left InvalidSourceEvidence)) Right (ledgerId "00000000-0000-4000-8000-000000000000")
  entries <- sequence [entry row mapped definition | (row, mapped, definition) <- zip3 rows mappings supplied]
  let snapshot = snapshotDigest source reference rows
      mapped = mappingDigest snapshot entries
      definitions = [(definition, evidence) | (_, definition, evidence) <- entries]
      batch = L.AdoptionBatch (source <> "\nsnapshot-evidence:" <> reference) snapshot mapped (Codec.definitionsDigest (map fst definitions)) (length definitions)
      uuid = "00000000-0000-4000-8000-000000000000"
      event body context = L.LedgerEvent 1 uuid identity uuid Nothing "2026-10-16T00:00:00.999999999999Z" audit "0.1.0.0" context body
  -- Validate each full serialized entry before initialization. Descriptors may
  -- approach the wire bound even when individual user fields are valid.
  mapM_
    ( \(definition, evidence) -> do
        context <- either (const (Left MappingDefinitionMismatch)) Right (Codec.contextFor definition Nothing)
        let encoded = event (L.AdoptEntry definition evidence) (Just context)
        either (const (Left InvalidSourceEvidence)) pure (Codec.validateEvent encoded)
        require AdoptionBatchTooLarge (BS.length (Codec.encodeEvent encoded) <= 61440)
    )
    definitions
  mapM_
    ( \body -> do
        let encoded = event body Nothing
        either (const (Left InvalidSourceEvidence)) pure (Codec.validateEvent encoded)
        require AdoptionBatchTooLarge (BS.length (Codec.encodeEvent encoded) <= 61440)
    )
    [L.StartAdoption batch, L.CompleteAdoption mapped]
  pure (batch, definitions)
  where
    entry (LegacyRow row _ checksum) (AdoptionMapping mapped component migration digest evidence) definition = do
      require MappingOrderMismatch (row == mapped)
      require MappingDefinitionMismatch (component == L.component definition && migration == L.migration definition && digest == L.checksum definition)
      let sourceRefs = ["source:" <> source, "snapshot-evidence:" <> reference, "source-row:" <> row]
      accepted <- case evidence of
        Attested refs -> pure (L.Evidence L.OperatorAttestation (sourceRefs ++ refs))
        Verified bytes refs -> case checksum of
          Nothing -> Left HistoricalChecksumRequired
          Just (SourceChecksum algorithm historical) -> do
            require HistoricalChecksumMismatch (algorithm == "sha256" && Hash.digestHex (Hash.digest bytes) == historical)
            pure (L.Evidence L.PredecessorChecksum (sourceRefs ++ ["historical-algorithm:" <> algorithm, "historical-checksum:" <> historical] ++ refs))
      pure (row, definition, accepted)

-- An identical committed batch is inspection-only, including after later native
-- execution. Every mutating path must match its fresh expected observation.
checkHistory :: AdoptionRequest -> L.AdoptionBatch -> [(L.Definition, L.Evidence)] -> R.HistoryReport -> Either AdoptionError Bool
checkHistory (AdoptionRequest target expected authorization _ _ _ _ _ _) batch entries (R.HistoryReport observed _ presence position _ events migrations unresolved) = do
  require AdoptionTargetMismatch (target == observed)
  case (expected, position) of
    (L.ExpectPosition (L.LedgerPosition wanted _ _), Just (L.LedgerPosition actual _ _)) -> require AdoptionTargetMismatch (wanted == actual)
    _ -> pure ()
  case (authorization, position) of
    (Just (L.InitializationAuthorization _ wanted _), Just (L.LedgerPosition actual _ _)) -> require AdoptionTargetMismatch (wanted == actual)
    _ -> pure ()
  let batches = [fromSummary summary | R.HistoryEvent _ _ _ _ _ _ _ _ _ (R.AdoptionBeginning summary) <- events]
      committed = [completion | R.HistoryEvent _ _ _ _ _ _ _ _ _ (R.AdoptionCompletion completion) <- events]
      actual = [(definition, level, refs) | R.HistoryEvent _ _ _ _ _ _ _ _ _ (R.AdoptedDefinition definition (R.EvidenceSummary level refs)) <- events]
      wanted = [(Reporting.summaryDefinition definition, level, refs) | (definition, L.Evidence level refs) <- entries]
      L.AdoptionBatch _ _ digest _ _ = batch
  if batches == [batch] && committed == [digest]
    then require AdoptionBatchConflict (actual == wanted) >> Right True
    else do
      require AdoptionExpectedStateMismatch (case expected of L.ExpectAbsent _ -> presence `elem` [R.LedgerMissing, R.LedgerUninitialized] && isNothing position; L.ExpectPosition value -> position == Just value)
      require AdoptionNativeConflict (null [() | R.HistoryEvent _ _ _ _ _ _ _ _ _ (R.MigrationDefinition _) <- events])
      case batches of
        [] -> require AdoptionNativeConflict (null migrations && not unresolved)
        [existing] -> require AdoptionBatchConflict (existing == batch && null committed && actual == take (length actual) wanted)
        _ -> Left AdoptionBatchConflict
      pure False
  where
    fromSummary (R.AdoptionSummary source snapshot mapped definitions count) = L.AdoptionBatch source snapshot mapped definitions count