diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java b/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java
new file mode 100644
index 00000000..7796a5ea
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ProvisionalContentTracker.java
@@ -0,0 +1,180 @@
+package vip.mate.channel;
+
+import io.micrometer.core.instrument.Metrics;
+import lombok.extern.slf4j.Slf4j;
+import vip.mate.agent.ContentKind;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Single authority for the "provisional narration" lifecycle shared by every
+ * user-facing surface.
+ *
+ *
A {@link ContentKind#PRE_TOOL_NARRATION} span is written before any tool
+ * observation of its turn, in a completion that goes on to call tools — it may
+ * be process narration or a fully fabricated rehearsal of the result. The
+ * policy, identical everywhere:
+ *
+ *
+ * - stage it instead of publishing (it may stay visible transiently, e.g.
+ * in a live progress bubble or a running SSE segment);
+ * - the turn's next content span (grounded narration or final answer)
+ * supersedes it — it must not become permanent output;
+ * - if the turn ends with no later content at all, it is committed: with
+ * no replacement it is everything the user gets.
+ *
+ *
+ * Grounded narrations and final answers never stage — they publish
+ * directly. Producers that predate the kind tag emit {@code null} kinds; the
+ * streaming API accepts a caller-supplied structural fallback signal for that
+ * case, and the segment-marking API leaves untagged timelines to the legacy
+ * structural detector.
+ *
+ *
Instances are single-turn and not thread-safe — create one per stream
+ * consumption, confine to the consuming thread (matches how channel adapters
+ * drain a turn's {@code Flux} today).
+ */
+@Slf4j
+public final class ProvisionalContentTracker {
+
+ /** Marker value stored in {@code supersededReason} — same wire value the
+ * legacy structural detector writes, so the UI needs no new vocabulary. */
+ public static final String REASON_PRE_TOOL_CONTENT_REPLACED =
+ "pre_tool_content_replaced_by_post_tool_answer";
+
+ private static final String METRIC_SUPERSEDED = "mateclaw.narration.superseded";
+
+ /** Where the supersede happened — metric tag, one value per surface. */
+ private final String surface;
+
+ private String pendingText;
+ private boolean pendingProvisional;
+
+ public ProvisionalContentTracker(String surface) {
+ this.surface = surface;
+ }
+
+ /**
+ * Stage a per-round narration. Returns the previous staged
+ * narration if the new arrival makes it publishable, or {@code null} when
+ * there is nothing to publish (no previous, or the previous was
+ * provisional and is now superseded by this later content).
+ *
+ * @param kind producer-assigned kind; {@code null} for
+ * pre-tag producers
+ * @param observedSinceLast structural fallback used only when {@code kind}
+ * is null: whether a tool observation completed
+ * since the previous narration (the pre-tag
+ * online rule)
+ */
+ public String stageNarration(String text, ContentKind kind, boolean observedSinceLast) {
+ boolean provisional = kind != null
+ ? kind == ContentKind.PRE_TOOL_NARRATION
+ : !observedSinceLast;
+ String previous = pendingText;
+ boolean previousProvisional = pendingProvisional;
+ pendingText = text;
+ pendingProvisional = provisional;
+ if (previous == null) {
+ return null;
+ }
+ if (previousProvisional) {
+ recordSuperseded(previous);
+ return null;
+ }
+ return previous;
+ }
+
+ /**
+ * Resolve the staged narration at turn end. Returns the text to publish,
+ * or {@code null} when nothing remains (no staged narration, or it was
+ * provisional and the turn produced final content that replaces it).
+ *
+ * @param hasFinalContent whether the turn produced a final answer — with
+ * one, a provisional narration is superseded; with
+ * none, even a provisional narration commits (no
+ * replacement exists)
+ */
+ public String settle(boolean hasFinalContent) {
+ String text = pendingText;
+ boolean provisional = pendingProvisional;
+ pendingText = null;
+ pendingProvisional = false;
+ if (text == null) {
+ return null;
+ }
+ if (provisional && hasFinalContent) {
+ recordSuperseded(text);
+ return null;
+ }
+ return text;
+ }
+
+ private void recordSuperseded(String text) {
+ log.info("[{}] provisional narration superseded by later content ({} chars dropped from permanent output)",
+ surface, text.length());
+ Metrics.counter(METRIC_SUPERSEDED, "surface", surface).increment();
+ }
+
+ // ==================== Persisted-timeline marking ====================
+
+ /**
+ * Whether the persisted segments timeline carries producer-assigned kind
+ * tags — i.e. whether {@link #markSuperseded(List, String)} is applicable
+ * or the caller should fall back to structural detection.
+ */
+ public static boolean hasKindTags(List