diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,27 @@
 
 ## [Unreleased]
 
+## [0.13.0.0] - 2026-11-08
+
+### Added
+
+- `okf log add --preview` shows the target path and exact proposed unified diff
+  without changing bundle files or directories, using the normal writer's
+  target selection, validation and edit construction (IR-15).
+
+### Changed
+
+- Require okf-core ^>=0.13.0.0 for content-preserving log insertion and previews.
+- Upgrade `mori://shinzui/baikai/packages/baikai` to 0.10.1.0 for cancellation
+  fixes in traced calls.
+- **Breaking (library):** `LogAddOptions` adds the `preview` boolean field.
+
+### Fixed
+
+- Preserve existing log bytes when adding entries or bootstrapping profiles,
+  including adoption prose, tables, historical Markdown and CRLF line endings.
+  Refuse unsupported or ambiguous insertion before writing the log (BUG-2).
+
 ## [0.12.0.1] - 2026-10-28
 
 ### Fixed
diff --git a/help/log.md b/help/log.md
--- a/help/log.md
+++ b/help/log.md
@@ -49,12 +49,30 @@
   okf log add BUNDLE --kind Update -m "Reorganized the bundle"
   okf log add BUNDLE CONCEPT_ID --kind Update -m "..." --date 2026-06-23
 
+  Review an exact proposed edit before writing:
+
+    okf log add BUNDLE --preview -m "Refreshed schema" --date 2026-06-23
+    okf log add BUNDLE -m "Refreshed schema" --date 2026-06-23
+
+  --preview prints a unified diff with the target path, additions, deletions
+  and context. A new log is compared against /dev/null. It creates or changes
+  no bundle files or directories and needs no confirmation. Supply BUNDLE to
+  run non-interactively; CONCEPT_ID works exactly as for a normal addition.
+  Preview and write share target selection, validation and edit construction.
+  Use an explicit --date and unchanged inputs to write the reviewed content.
+
   With a CONCEPT_ID the entry goes in that concept's directory log.md, creating
   the file if absent. Without one it goes in the root log.md. --kind is the
   leading bold label and defaults to Update; -m/--message is required; --date
   writes a specific YYYY-MM-DD heading instead of today's in UTC.
 
   Re-running the same command adds another bullet; it does not deduplicate.
+
+  Additions preserve existing bytes, including prose, tables, Markdown formatting
+  and LF or CRLF line endings. Entries are prepended within a date; new dates
+  are inserted newest first. Logs need one top-level title and unique date
+  headings in newest-first order. Unsafe or unsupported insertion fails before
+  writing the log, with an explanation of what needs repair.
 
 VALIDATION
 
diff --git a/okf-cli.cabal b/okf-cli.cabal
--- a/okf-cli.cabal
+++ b/okf-cli.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.4
 name: okf-cli
-version: 0.12.0.1
+version: 0.13.0.0
 synopsis: Command-line interface for Open Knowledge Format bundles
 description:
   okf-cli provides the @okf@ executable for working with Open Knowledge Format
@@ -67,7 +67,7 @@
   autogen-modules: Paths_okf_cli
   build-depends:
     aeson >=2.2 && <2.4,
-    baikai ^>=0.10.0.0,
+    baikai ^>=0.10.1.0,
     baikai-claude ^>=0.7.3.0,
     baikai-kit ^>=0.4.0.3,
     baikai-openai ^>=0.7.3.0,
@@ -81,7 +81,7 @@
     generic-lens >=2.2 && <2.4,
     githash ^>=0.1,
     lens ^>=5.3,
-    okf-core ^>=0.12.0.1,
+    okf-core ^>=0.13.0.0,
     optparse-applicative >=0.19 && <0.20,
     process >=1.6 && <1.7,
     terminal-size >=0.3.4 && <0.4,
@@ -95,18 +95,19 @@
   hs-source-dirs: test
   build-depends:
     aeson >=2.2 && <2.4,
-    baikai ^>=0.10.0.0,
+    baikai ^>=0.10.1.0,
     baikai-claude ^>=0.7.3.0,
     baikai-kit ^>=0.4.0.3,
     baikai-openai ^>=0.7.3.0,
     base >=4.21 && <5,
+    bytestring >=0.11 && <0.13,
     containers >=0.6 && <0.8,
     directory,
     filepath,
     generic-lens >=2.2 && <2.4,
     lens ^>=5.3,
     okf-cli,
-    okf-core ^>=0.12.0.1,
+    okf-core ^>=0.13.0.0,
     optparse-applicative >=0.19,
     process >=1.6 && <1.7,
     temporary,
diff --git a/src/Okf/Cli.hs b/src/Okf/Cli.hs
--- a/src/Okf/Cli.hs
+++ b/src/Okf/Cli.hs
@@ -69,6 +69,7 @@
 import Data.Aeson qualified as Aeson
 import Data.Aeson.Key qualified as AesonKey
 import Data.Aeson.KeyMap qualified as KeyMap
+import Data.ByteString qualified as Bytes
 import Data.ByteString.Lazy qualified as LazyBytes
 import Data.ByteString.Lazy.Char8 qualified as LazyByteString
 import Data.Char (isDigit)
@@ -324,7 +325,8 @@
   { conceptId :: !(Maybe Text),
     kind :: !Text,
     message :: !Text,
-    date :: !(Maybe Text)
+    date :: !(Maybe Text),
+    preview :: !Bool
   }
   deriving stock (Show, Eq)
 
@@ -621,7 +623,9 @@
         "add"
         ( info
             (logAddOptionsToCommand <$> optionalBundleArgument <*> logAddOptionsParser <**> helper)
-            (progDesc "Append an entry to the nearest log.md")
+            ( progDesc "Append an entry to the nearest log.md"
+                <> footer "Review before writing: okf log add BUNDLE --preview -m 'Refreshed schema' --date 2026-06-23. Run again without --preview to write the reviewed edit."
+            )
         )
     )
 
@@ -663,6 +667,7 @@
                 <> help "Entry date; defaults to today in UTC"
             )
       )
+    <*> switch (long "preview" <> help "Show the target path and exact proposed diff without writing files or directories")
 
 graphOptionsParser :: Parser GraphOptions
 graphOptionsParser =
@@ -1877,7 +1882,7 @@
         either (\err -> dieText ("profile init: index generation failed: " <> renderBundleError err <> "\n" <> indexRecovery)) pure indexResult
       Text.IO.putStrLn ("Declared " <> declarationText <> " in " <> Text.pack (destination </> "index.md"))
       bootstrapPhase "log write" "The verified descriptor and indexes remain. Inspect log.md before adding the Adoption entry; do not append it twice." $
-        runLogAdd destination (LogAddOptions Nothing "Adoption" message (Just entryDate))
+        runLogAdd destination (LogAddOptions Nothing "Adoption" message (Just entryDate) False)
       Text.IO.putStrLn "Bootstrap files have been written; validation follows."
       Text.IO.putStrLn ("Enforce in CI: okf validate " <> destinationArg <> " --strict --profile " <> descriptorArg <> " --profile-enforce --log-enforce")
       Text.IO.putStrLn ("Read the conventions: okf profile document --profile " <> descriptorArg)
@@ -2322,19 +2327,26 @@
   runLogAdd resolvedBundle addOptions
 
 runLogAdd :: FilePath -> LogAddOptions -> IO ()
-runLogAdd bundlePath LogAddOptions {conceptId, kind, message, date} = do
+runLogAdd bundlePath LogAddOptions {conceptId, kind, message, date, preview = previewOnly} = do
   entryDate <- maybe todayDate pure date
   targetPath <- resolveLogTarget bundlePath conceptId
   let absolutePath = bundlePath </> targetPath
       entry = Log.LogEntry {Log.logKind = Just kind, Log.logText = message}
+      refuse reason = dieText ("log: refusing to update " <> Text.pack absolutePath <> ": " <> reason <> ". No log content was written.")
   exists <- doesFileExist absolutePath
-  existingLog <-
+  original <-
     if exists
-      then Log.parseLog <$> Text.IO.readFile absolutePath
-      else pure (emptyLogFor targetPath)
-  createDirectoryIfMissing True (FilePath.takeDirectory absolutePath)
-  Text.IO.writeFile absolutePath (Log.serializeLog (Log.appendLogEntry entryDate entry existingLog))
-  Text.IO.putStrLn ("Wrote " <> Text.pack targetPath <> " for " <> entryDate)
+      then do
+        bytes <- Bytes.readFile absolutePath
+        Just <$> either (\_ -> refuse "log is not valid UTF-8") pure (Text.Encoding.decodeUtf8' bytes)
+      else pure Nothing
+  updated <- either refuse pure (Log.insertLogEntry entryDate entry (fromMaybe (Log.serializeLog (emptyLogFor targetPath)) original))
+  if previewOnly
+    then Bytes.putStr (Text.Encoding.encodeUtf8 (Log.renderLogDiff absolutePath original updated))
+    else do
+      createDirectoryIfMissing True (FilePath.takeDirectory absolutePath)
+      Bytes.writeFile absolutePath (Text.Encoding.encodeUtf8 updated)
+      Text.IO.putStrLn ("Wrote " <> Text.pack targetPath <> " for " <> entryDate)
 
 resolveLogTarget :: FilePath -> Maybe Text -> IO FilePath
 resolveLogTarget _ Nothing =
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -15,6 +15,7 @@
 import Data.Aeson qualified as Aeson
 import Data.Aeson.KeyMap qualified as KeyMap
 import Data.Bifunctor (first)
+import Data.ByteString qualified as Bytes
 import Data.Char (isControl)
 import Data.Foldable (toList, traverse_)
 import Data.Generics.Labels ()
@@ -23,6 +24,7 @@
 import Data.Map.Strict qualified as Map
 import Data.Maybe (listToMaybe)
 import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text.Encoding
 import Data.Text.IO qualified as Text.IO
 import Data.Time.Calendar (fromGregorian)
 import Data.Time.Clock (UTCTime (..))
@@ -51,11 +53,11 @@
 import Okf.Query (ConceptFilter (..), ConceptPredicate (..), FieldSelector (..), SortDirection (..), SortKey (..), WhereCondition (..), filterConcepts, filterConceptsWhere, parseSortKey, parseWhereCondition, sortConcepts)
 import Okf.Validation (ValidationProfile (..), validateBundle)
 import Options.Applicative
-import System.Directory (Permissions (..), canonicalizePath, copyFile, createDirectoryIfMissing, createFileLink, doesDirectoryExist, doesFileExist, getCurrentDirectory, getPermissions, getTemporaryDirectory, listDirectory, makeAbsolute, removeDirectoryRecursive, setModificationTime, setPermissions, withCurrentDirectory)
+import System.Directory (Permissions (..), canonicalizePath, copyFile, createDirectoryIfMissing, createFileLink, doesDirectoryExist, doesFileExist, getCurrentDirectory, getPermissions, getTemporaryDirectory, listDirectory, makeAbsolute, removeDirectoryRecursive, removeFile, setModificationTime, setPermissions, withCurrentDirectory)
 import System.Environment (getArgs, getExecutablePath, lookupEnv, setEnv, unsetEnv, withArgs)
 import System.Exit (ExitCode (..), exitFailure)
 import System.FilePath (normalise, takeDirectory, takeFileName, (</>))
-import System.IO (IOMode (..), hClose, hFlush, stdout, withFile)
+import System.IO (Handle, IOMode (..), hClose, hFlush, stderr, stdout, withFile)
 import System.IO.Temp (createTempDirectory)
 import System.Process (readProcessWithExitCode)
 import System.Timeout (timeout)
@@ -88,6 +90,8 @@
   profilePickerExitCodes <- testProfilePickerExitCodes
   effectiveProfileSources <- testEffectiveProfileSources
   logAddWrites <- testLogAddWritesFile
+  logAddPreserves <- testLogAddPreservesHistory
+  logAddPreview <- testLogAddPreview
   aliasStartup <- testAliasStartup
   aliasConfiguration <- testAliasConfiguration
   configDefaults <- testConfigDefaults
@@ -168,7 +172,7 @@
                     Nothing
                     False
                     Nothing
-                    (LogAdd (LogAddOptions Nothing "Update" "Root update" Nothing))
+                    (LogAdd (LogAddOptions Nothing "Update" "Root update" Nothing False))
                 )
             ),
           parseCommandMatches
@@ -178,7 +182,7 @@
                     (Just "b")
                     False
                     Nothing
-                    (LogAdd (LogAddOptions Nothing "Update" "Root update" Nothing))
+                    (LogAdd (LogAddOptions Nothing "Update" "Root update" Nothing False))
                 )
             ),
           parseCommandMatches
@@ -272,7 +276,8 @@
                       { conceptId = Just "tables/users",
                         kind = "Update",
                         message = "Refreshed schema",
-                        date = Just "2026-06-23"
+                        date = Just "2026-06-23",
+                        preview = False
                       }
               },
           parseSucceeds ["graph", "bundle", "--json"],
@@ -704,6 +709,8 @@
           parseShowsInfo ["--version"],
           parseFails ["hello"],
           logAddWrites,
+          logAddPreserves,
+          logAddPreview,
           bundleDiscoveryListing,
           profileDiscoveryListing,
           profilePickerExitCodes,
@@ -2611,17 +2618,184 @@
                   { conceptId = Just "tables/users",
                     kind = "Update",
                     message = "Refreshed schema",
-                    date = Just "2026-06-23"
+                    date = Just "2026-06-23",
+                    preview = False
                   }
           }
     )
   written <- Text.IO.readFile (root </> "tables" </> "log.md")
+  -- A concept addition must keep selecting its directory log, preserving both
+  -- existing nested history and the independent root log.
+  let rootLog = "# Root Log\n\nRoot-only provenance.\n"
+      nestedLog = "# Tables Log\n\n## 2026-06-22\n\nNested adoption evidence.\n\n- **Adoption**: Keep `column_name`.\n"
+  Text.IO.writeFile (root </> "log.md") rootLog
+  Text.IO.writeFile (root </> "tables" </> "log.md") nestedLog
+  runLogAdd root (LogAddOptions (Just "tables/users") "Update" "Nested update" (Just "2026-06-23") False)
+  nestedAfter <- Text.IO.readFile (root </> "tables" </> "log.md")
+  rootAfter <- Text.IO.readFile (root </> "log.md")
   removeDirectoryRecursive root
   pure
     ( "## 2026-06-23" `Text.isInfixOf` written
         && "* **Update**: Refreshed schema" `Text.isInfixOf` written
+        && rootAfter == rootLog
+        && nestedAfter == Text.replace "## 2026-06-22" "## 2026-06-23\n* **Update**: Nested update\n\n## 2026-06-22" nestedLog
     )
 
+-- Compare persisted UTF-8 bytes, including local history that exists only in
+-- the working tree. Each expected result differs by exactly one insertion.
+testLogAddPreservesHistory :: IO Bool
+testLogAddPreservesHistory = do
+  temp <- getTemporaryDirectory
+  bracket (createTempDirectory temp "okf-cli-log-preserve") removeDirectoryRecursive $ \root -> do
+    let prefix = "# Bundle Update Log\n\nIntro: 東京.\n\n"
+        heading = "## 2026-11-06\n"
+        history = "\nAdopted guides; retain this provenance.\n\n| Original | Retained |\n| --- | --- |\n| old-guide.md | guide.md |\n\n- **Adoption**: Keep `guide_name` and [guide](guide.md), escaped \\_text\\_.\n\n```md\n## 2099-01-01\n```\n\n> ## 2098-01-01\n\n<!--\n## 2097-01-01\n-->\n\n- Final entry without a newline."
+        original = prefix <> heading <> history
+        bullet = "* **Update**: Add one entry"
+        cases =
+          [ ("same day", "2026-11-06", original, prefix <> heading <> "\n" <> bullet <> "\n\n" <> history),
+            ("newest day", "2026-11-07", original, prefix <> "## 2026-11-07\n" <> bullet <> "\n\n" <> heading <> history),
+            ("oldest day", "2026-11-05", original, original <> "\n\n## 2026-11-05\n" <> bullet <> "\n"),
+            ("middle day", "2026-11-07", prefix <> "## 2026-11-08\n- Later.\n\n" <> heading <> history, prefix <> "## 2026-11-08\n- Later.\n\n## 2026-11-07\n" <> bullet <> "\n\n" <> heading <> history),
+            ("setext day", "2026-11-06", prefix <> "2026-11-06\n----------\n" <> history, prefix <> "2026-11-06\n----------\n\n" <> bullet <> "\n" <> history),
+            ("title only", "2026-11-06", "# Log\n", "# Log\n\n## 2026-11-06\n" <> bullet <> "\n")
+          ]
+        encode = Text.Encoding.encodeUtf8
+        run day = try @ExitCode (runLogAdd root (LogAddOptions Nothing "Update" "Add one entry" (Just day) False))
+    preserved <- forM [(label, day, convert before, convert after) | convert <- [id, Text.replace "\n" "\r\n"], (label, day, before, after) <- cases] $ \(label, day, before, expected) -> do
+      Bytes.writeFile (root </> "log.md") (encode before)
+      outcome <- run day
+      actual <- Bytes.readFile (root </> "log.md")
+      let passed = outcome == Right () && actual == encode expected
+      unless passed (putStrLn ("log preservation failed: " <> label <> "\n" <> show actual))
+      pure passed
+    refused <- forM
+      [ "# Log\n\n## invalid\n- Keep.\n",
+        "# Log\n\n## 2026-11-06\n- Keep.\n\n## 2026-11-06\n- Duplicate.\n",
+        "# Log\n\n## 2026-11-05\n- Older.\n\n## 2026-11-06\n- Newer.\n",
+        "# Log\n\n# Another title\n",
+        "# Log\r\r## 2026-11-06\r- Keep.",
+        "# Log\n\n## 2026-11-06\n- Keep.\n\n```\nUnclosed fence",
+        "# Log\n\n## 2026-11-06\n- Keep.\n\n<!-- unclosed HTML",
+        Bytes.pack [255, 254, 128]
+      ]
+      $ \before -> do
+        Bytes.writeFile (root </> "log.md") before
+        outcome <- run "2026-11-05"
+        actual <- Bytes.readFile (root </> "log.md")
+        pure (outcome == Left (ExitFailure 1) && actual == before)
+    invalidDates <- forM ["2026-02-30", "yesterday"] $ \day -> do
+      Bytes.writeFile (root </> "log.md") (encode original)
+      outcome <- run day
+      actual <- Bytes.readFile (root </> "log.md")
+      pure (outcome == Left (ExitFailure 1) && actual == encode original)
+    missingResult <- try @ExitCode (runLogAdd (root </> "missing") (LogAddOptions Nothing "Update" "Add one entry" (Just "2026-02-30") False))
+    missingCreated <- doesDirectoryExist (root </> "missing")
+    let referenceHistory = original <> "\n\n[source]: guide.md\n"
+        referenceExpected = prefix <> "## 2026-11-07\n* **Update**: See [guide][source].\n\n" <> heading <> history <> "\n\n[source]: guide.md\n"
+    Bytes.writeFile (root </> "log.md") (encode referenceHistory)
+    referenceResult <- try @ExitCode (runLogAdd root (LogAddOptions Nothing "Update" "See [guide][source]." (Just "2026-11-07") False))
+    referenceAfter <- Bytes.readFile (root </> "log.md")
+    pure (and preserved && and refused && and invalidDates && missingResult == Left (ExitFailure 1) && not missingCreated && referenceResult == Right () && referenceAfter == encode referenceExpected)
+
+-- Exercise the actual command parser and compare a patch applied by Git with
+-- the normal writer. Snapshot directories as well as file bytes before Git or
+-- the writer runs, so preview cannot quietly create an absent log directory.
+testLogAddPreview :: IO Bool
+testLogAddPreview = do
+  temp <- getTemporaryDirectory
+  bracket (createTempDirectory temp "okf-cli-log-preview" >>= canonicalizePath) removeDirectoryRecursive $ \scratch -> do
+    let mixed = "# Log\n\nAdopted guides: 東京.\n\n| Original | Retained |\n| --- | --- |\n| old.md | guide.md |\n\n## 2026-11-06\n\n- **Adoption**: Keep `guide_name`, [guide](guide.md), and \\_text\\_."
+        cases =
+          [ ("root", Nothing, "log.md", Just mixed, "2026-11-07"),
+            ("same-day", Nothing, "log.md", Just mixed, "2026-11-06"),
+            ("oldest", Nothing, "log.md", Just mixed, "2026-11-05"),
+            ("crlf", Nothing, "log.md", Just (Text.replace "\n" "\r\n" mixed), "2026-11-05"),
+            ("nested", Just "tables/deep/users", "tables/deep/log.md", Just mixed, "2026-11-07"),
+            ("new-log", Just "tables/deep/users", "tables/deep/log.md", Nothing, "2026-11-07"),
+            ("new-directory", Just "absent/deep/users", "absent/deep/log.md", Nothing, "2026-11-07"),
+            ("new-root", Nothing, "log.md", Nothing, "2026-11-07")
+          ]
+        run args = captureStdout (captureHandle stderr (try @ExitCode (withArgs args runCli)))
+        snapshot root = do
+          let walk relative = do
+                names <- List.sort <$> listDirectory (root </> relative)
+                fmap concat . forM names $ \name -> do
+                  let path = relative </> name
+                  directory <- doesDirectoryExist (root </> path)
+                  if directory
+                    then ((path, Nothing) :) <$> walk path
+                    else do
+                      bytes <- Bytes.readFile (root </> path)
+                      pure [(path, Just bytes)]
+          walk ""
+    previews <- forM cases $ \(label, selectedConcept, target, original, day) -> do
+      let root = scratch </> label
+          targetPath = root </> target
+          args = ["log", "add", root] <> maybe [] (pure . Text.unpack) selectedConcept <> ["-m", "Reviewed update: 東京", "--date", day]
+      createDirectoryIfMissing True (root </> "tables/deep")
+      Text.IO.writeFile (root </> "index.md") "---\nokf_version: '0.2'\n---\n\n# Bundle\n"
+      Text.IO.writeFile (root </> "tables/deep/users.md") "---\ntype: Table\n---\n\n# Users\n"
+      traverse_ (\text -> Text.IO.writeFile targetPath text) original
+      before <- snapshot root
+      ((previewResult, _), patch) <- run (args <> ["--preview"])
+      after <- snapshot root
+      -- Apply the emitted diff, retaining CRs and no-final-newline markers.
+      (patchResult, _, patchError) <- withCurrentDirectory root (readProcessWithExitCode "git" ["apply", "--unsafe-paths", "-p0", "-"] (Text.unpack patch))
+      patchedExists <- doesFileExist targetPath
+      patched <- if patchedExists then Bytes.readFile targetPath else pure ""
+      case original of
+        Just text -> Bytes.writeFile targetPath (Text.Encoding.encodeUtf8 text)
+        Nothing -> whenFileExists targetPath (removeFile targetPath)
+      ((writeResult, _), _) <- run args
+      written <- Bytes.readFile targetPath
+      let passed =
+            previewResult == Right ()
+              && before == after
+              && patchResult == ExitSuccess
+              && writeResult == Right ()
+              && patched == written
+              && Text.pack targetPath `Text.isInfixOf` patch
+              && "+* **Update**: Reviewed update: 東京" `Text.isInfixOf` patch
+              && (maybe False (const True) original || "--- /dev/null\n" `Text.isPrefixOf` patch)
+      unless passed (putStrLn ("log preview failed: " <> label <> "\n" <> Text.unpack patch <> patchError))
+      pure passed
+    let root = scratch </> "errors"
+        baseArgs = ["log", "add", root, "-m", "Update", "--date", "2026-11-07"]
+        badCases =
+          [ ("# Log\n", ["log", "add", root, "-m", "Update", "--date", "2026-02-30"]),
+            ("# Log\n", ["log", "add", root, "../escape", "-m", "Update"]),
+            ("# Log\n", ["log", "add", root, "--preview"]),
+            ("# Log\n", ["log", "add", root, "-m", "first\n\n* second"]),
+            ("# Log\n\n## invalid\n- Keep.\n", baseArgs),
+            (Bytes.pack [255, 254, 128], baseArgs)
+          ]
+    createDirectoryIfMissing True root
+    errors <- forM badCases $ \(bytes, args) -> do
+      Bytes.writeFile (root </> "log.md") bytes
+      before <- snapshot root
+      ((normalResult, normalError), _) <- run (filter (/= "--preview") args)
+      ((previewResult, previewError), output) <- run (args <> ["--preview" | "--preview" `notElem` args])
+      after <- snapshot root
+      pure (normalResult == Left (ExitFailure 1) && previewResult == normalResult && previewError == normalError && Text.null output && before == after)
+    -- A root that does not exist must remain absent, including its parents.
+    before <- snapshot scratch
+    ((absentResult, _), _) <- run ["log", "add", scratch </> "missing/child", "--preview", "-m", "New root", "--date", "2026-11-07"]
+    after <- snapshot scratch
+    let documentedHelp = case execParserPure defaultPrefs parserInfo ["log", "add", "--help"] of
+          Failure failure ->
+            let (helpText, code) = renderFailure failure "okf"
+                normalized = Text.unwords (Text.words (Text.pack helpText))
+             in code == ExitSuccess
+                  && "--preview" `Text.isInfixOf` normalized
+                  && "okf log add BUNDLE --preview -m 'Refreshed schema' --date 2026-06-23" `Text.isInfixOf` normalized
+          _ -> False
+    pure (and previews && and errors && absentResult == Right () && before == after && documentedHelp)
+  where
+    whenFileExists path action = do
+      exists <- doesFileExist path
+      if exists then action else pure ()
+
 -- | Discovery stays sorted and duplicate-free across repeated roots, an empty
 -- search is successful, and JSON metadata failure leaves a path-only entry.
 testBundleDiscoveryListing :: IO Bool
@@ -3857,16 +4031,19 @@
       versionOk <- success "unparseable version" (rejectedVersion == Left (ExitFailure 1) && indexAfter == oldIndex && not descriptorAfter)
       let existing = root </> "existing"
           conceptText = "---\ntype: Note\ntitle: Keep me\n---\nOriginal bytes.\n"
+          existingHistory = "## 2026-09-18\n\nRetain this provenance: 東京.\n\n| Original | Retained |\n| --- | --- |\n| old.md | nested/concept.md |\n\n- **Update**: Preserve `column_name`, [concept](nested/concept.md) and \\_escaping\\_.\n"
+          existingLog = "# Bundle Update Log\n\n" <> existingHistory
       createDirectoryIfMissing True (existing </> "nested")
       Text.IO.writeFile (existing </> "nested" </> "concept.md") conceptText
       Text.IO.writeFile (existing </> "index.md") "---\nokf_version: \"0.3\"\n---\n"
-      Text.IO.writeFile (existing </> "log.md") "# Bundle Update Log\n\n## 2026-09-18\n* **Update**: Preserve this entry.\n"
+      Text.IO.writeFile (existing </> "log.md") existingLog
       existingResult <- run (options existing)
       preserved <- Text.IO.readFile (existing </> "nested" </> "concept.md")
       higher <- readBundleVersion existing
       appended <- Text.IO.readFile (existing </> "log.md")
       nestedIndex <- doesFileExist (existing </> "nested" </> "index.md")
-      existingOk <- success "existing bundle" (existingResult == Right () && preserved == conceptText && higher == Right (VersionDeclared (OkfVersion 0 3)) && "Preserve this entry." `Text.isInfixOf` appended && Text.count "**Adoption**" appended == 1 && nestedIndex)
+      let (adoptionPrefix, historyAfter) = Text.breakOn existingHistory appended
+      existingOk <- success "existing bundle" (existingResult == Right () && preserved == conceptText && higher == Right (VersionDeclared (OkfVersion 0 3)) && historyAfter == existingHistory && "# Bundle Update Log\n\n## 2026-09-19\n* **Adoption**:" `Text.isPrefixOf` adoptionPrefix && Text.count "**Adoption**" appended == 1 && nestedIndex)
       -- Invalid definitions load but must fail compilation before mutation.
       let invalidRegistry = root </> "invalid.dhall"
       Text.IO.writeFile invalidRegistry ("let registry = " <> Text.pack absoluteRegistry <> " in registry.postgresql with okfVersion = \"invalid\"")
@@ -4051,15 +4228,18 @@
 
 -- | Run an action with stdout redirected to a file, returning what it printed.
 captureStdout :: IO a -> IO (a, Text.Text)
-captureStdout action = do
+captureStdout = captureHandle stdout
+
+captureHandle :: Handle -> IO a -> IO (a, Text.Text)
+captureHandle stream action = do
   temporary <- getTemporaryDirectory
   directory <- createTempDirectory temporary "okf-cli-capture"
   let path = directory </> "stdout"
   result <- withFile path WriteMode $ \handle -> do
-    hFlush stdout
-    saved <- hDuplicate stdout
-    hDuplicateTo handle stdout
-    action `finally` (hFlush stdout >> hDuplicateTo saved stdout >> hClose saved)
+    hFlush stream
+    saved <- hDuplicate stream
+    hDuplicateTo handle stream
+    action `finally` (hFlush stream >> hDuplicateTo saved stream >> hClose saved)
   output <- Text.IO.readFile path
   removeDirectoryRecursive directory
   pure (result, output)
