You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/23 11:00:32 UTC

(maka) branch feat/runtime-host-rust updated: feat(event-log): reclaim retired conversation material without losing accounting

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
     new 8aa3667fa0 feat(event-log): reclaim retired conversation material without losing accounting
8aa3667fa0 is described below

commit 8aa3667fa03fe93b2378c386be0ab2d2eb75c492
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 18:57:33 2026 +0800

    feat(event-log): reclaim retired conversation material without losing accounting
    
    Generated-by: Codex
---
 crates/event-log/migrations/0001_runtime.sql      |  88 +++++----
 crates/event-log/src/lib.rs                       |   2 +
 crates/event-log/src/prefix.rs                    |   5 +
 crates/event-log/src/schema.rs                    |   7 +
 crates/event-log/src/sessions.rs                  |   2 +
 crates/event-log/src/sessions/material.rs         | 186 ++++++++++++++++++
 crates/event-log/tests/archive.rs                 |   4 +
 crates/event-log/tests/session_copy.rs            |   5 +
 crates/event-log/tests/session_material.rs        | 223 ++++++++++++++++++++++
 crates/event-log/tests/tool_usage.rs              |  39 ++++
 crates/runtime-host/src/execution/plugins/root.rs |   1 +
 crates/runtime-host/src/execution/removal.rs      |  17 ++
 crates/runtime-host/src/server/sessions/copy.rs   |   1 +
 docs/rust-parity.md                               |   2 +-
 docs/rust-parity.zh-CN.md                         |   2 +-
 docs/rust-runtime.md                              |   8 +-
 docs/rust-runtime.zh-CN.md                        |   6 +-
 17 files changed, 560 insertions(+), 38 deletions(-)

diff --git a/crates/event-log/migrations/0001_runtime.sql b/crates/event-log/migrations/0001_runtime.sql
index 59f0e88898..82f189fb33 100644
--- a/crates/event-log/migrations/0001_runtime.sql
+++ b/crates/event-log/migrations/0001_runtime.sql
@@ -31,37 +31,45 @@ CREATE TABLE event_log (
         CASE WHEN kind IN ('model_requested', 'model_completed', 'model_interrupted',
                            'invocation_ended', 'auxiliary_model_started', 'auxiliary_model_settled',
                            'tool_dispatched', 'tool_settled', 'tool_rejected')
-        THEN COALESCE(json_extract(event_json, '$.recorded_at.secs_since_epoch'),
-                      json_extract(event_json, '$.started_at.secs_since_epoch'),
-                      json_extract(event_json, '$.completed_at.secs_since_epoch')) * 1000.0
-            + COALESCE(json_extract(event_json, '$.recorded_at.nanos_since_epoch'),
-                       json_extract(event_json, '$.started_at.nanos_since_epoch'),
-                       json_extract(event_json, '$.completed_at.nanos_since_epoch')) / 1000000.0
+        THEN COALESCE(json_extract(COALESCE(event_json, retained_json), '$.recorded_at.secs_since_epoch'),
+                      json_extract(COALESCE(event_json, retained_json), '$.started_at.secs_since_epoch'),
+                      json_extract(COALESCE(event_json, retained_json), '$.completed_at.secs_since_epoch')) * 1000.0
+            + COALESCE(json_extract(COALESCE(event_json, retained_json), '$.recorded_at.nanos_since_epoch'),
+                       json_extract(COALESCE(event_json, retained_json), '$.started_at.nanos_since_epoch'),
+                       json_extract(COALESCE(event_json, retained_json), '$.completed_at.nanos_since_epoch')) / 1000000.0
         END
     ) STORED,
     accounting_usage TEXT GENERATED ALWAYS AS (
         CASE
-            WHEN kind = 'model_completed' THEN json_extract(event_json, '$.fact.output.usage')
-            WHEN kind = 'model_observed' AND json_extract(event_json, '$.fact.event.kind') = 'finished'
-                THEN json_extract(event_json, '$.fact.event.data.usage')
-            WHEN kind = 'auxiliary_model_usage' THEN json_extract(event_json, '$.usage')
+            WHEN kind = 'model_completed' THEN json_extract(COALESCE(event_json, retained_json), '$.fact.output.usage')
+            WHEN kind = 'model_observed' AND json_extract(COALESCE(event_json, retained_json), '$.fact.event.kind') = 'finished'
+                THEN json_extract(COALESCE(event_json, retained_json), '$.fact.event.data.usage')
+            WHEN kind = 'auxiliary_model_usage' THEN json_extract(COALESCE(event_json, retained_json), '$.usage')
         END
     ) STORED,
     invocation_model TEXT GENERATED ALWAYS AS (
-        CASE WHEN kind = 'invocation_opened' THEN json_extract(event_json, '$.fact.configuration.model') END
+        CASE WHEN kind = 'invocation_opened' THEN json_extract(COALESCE(event_json, retained_json), '$.fact.configuration.model') END
     ) STORED,
     accounting_tool TEXT GENERATED ALWAYS AS (
         CASE
             WHEN kind IN ('tool_dispatched', 'tool_rejected') THEN json_object(
-                'invocation', json_extract(event_json, '$.invocation'),
-                'call', json_extract(event_json, '$.fact.call'),
-                'name', json_extract(event_json, '$.fact.name'),
-                'rejection', json_extract(event_json, '$.fact.reason.kind'))
+                'invocation', json_extract(COALESCE(event_json, retained_json), '$.invocation'),
+                'call', json_extract(COALESCE(event_json, retained_json), '$.fact.call'),
+                'name', json_extract(COALESCE(event_json, retained_json), '$.fact.name'),
+                'rejection', json_extract(COALESCE(event_json, retained_json), '$.fact.reason.kind'))
             WHEN kind = 'tool_settled' THEN json_object(
-                'outcome', json_extract(event_json, '$.fact.outcome.kind'))
+                'outcome', json_extract(COALESCE(event_json, retained_json), '$.fact.outcome.kind'))
         END
     ) STORED,
-    event_json TEXT NOT NULL,
+    event_json TEXT,
+    -- A collected body is absent, never a synthetic replayable RuntimeEvent.
+    retained_json TEXT CHECK(retained_json IS NULL OR json_valid(retained_json)),
+    body_digest TEXT,
+    event_session TEXT GENERATED ALWAYS AS (
+        json_extract(COALESCE(event_json, retained_json), '$.invocation.session_id')
+    ) STORED,
+    CHECK((event_json IS NOT NULL AND retained_json IS NULL AND body_digest IS NULL)
+       OR (event_json IS NULL AND retained_json IS NOT NULL AND body_digest IS NOT NULL)),
     CHECK(invocation_id IS NOT NULL OR operation_id IS NULL)
 );
 
@@ -254,7 +262,18 @@ CREATE TABLE "tool_result_payloads" (
 );
 
 CREATE VIEW runtime_events AS
-    SELECT * FROM event_log WHERE invocation_id IS NOT NULL;
+    SELECT * FROM event_log WHERE invocation_id IS NOT NULL AND event_json IS NOT NULL;
+
+-- Accounting can read compact retained proof, never conversation history.
+CREATE VIEW accounting_events AS
+    SELECT sequence, event_id, invocation_id, kind, operation_id,
+        accounting_at, accounting_usage, invocation_model, accounting_tool,
+        COALESCE(event_json, retained_json) AS event_json
+    FROM event_log;
+
+CREATE INDEX event_material_owner ON event_log(event_session, sequence)
+    WHERE event_json IS NOT NULL;
+CREATE INDEX event_proof_owner ON event_log(event_session, sequence);
 
 -- Copies retain original facts, never execution authority. Flatten membership
 -- at creation so descendants do not query mutable parent Session metadata.
@@ -290,6 +309,11 @@ CREATE TABLE session_revision_sources (
     PRIMARY KEY(session_id, sequence)
 );
 
+CREATE INDEX history_source_owners ON session_history_members(sequence, session_id);
+CREATE INDEX archive_source_owners ON session_history_members(archive_sequence, session_id)
+    WHERE archive_sequence IS NOT NULL;
+CREATE INDEX revision_source_owners ON session_revision_sources(sequence, session_id);
+
 CREATE VIEW session_history_events AS
     SELECT json_extract(event_json, '$.invocation.session_id') AS owner_session_id,
            0 AS inherited, NULL AS archive_sequence, e.* FROM runtime_events e
@@ -343,8 +367,8 @@ CREATE TABLE model_valuations (
 );
 
 CREATE INDEX model_usage_observation ON event_log(
-    invocation_id, json_extract(event_json, '$.fact.step_id'), sequence DESC
-) WHERE kind = 'model_observed' AND json_extract(event_json, '$.fact.event.kind') = 'finished';
+    invocation_id, json_extract(COALESCE(event_json, retained_json), '$.fact.step_id'), sequence DESC
+) WHERE kind = 'model_observed' AND json_extract(COALESCE(event_json, retained_json), '$.fact.event.kind') = 'finished';
 
 -- Count physical admissions, not history memberships or accepted responses.
 -- A rejected response can still have provider-reported usage. No mutable
@@ -370,19 +394,19 @@ WITH attempts AS (
             SELECT observed.accounting_usage
             FROM event_log observed INDEXED BY model_usage_observation
             WHERE observed.kind = 'model_observed'
-              AND json_extract(observed.event_json, '$.fact.event.kind') = 'finished'
+              AND json_extract(COALESCE(observed.event_json, observed.retained_json), '$.fact.event.kind') = 'finished'
               AND observed.invocation_id = request.invocation_id
-              AND json_extract(observed.event_json, '$.fact.step_id') = request.operation_id
+              AND json_extract(COALESCE(observed.event_json, observed.retained_json), '$.fact.step_id') = request.operation_id
             ORDER BY observed.sequence DESC LIMIT 1
         ), '{}') AS usage
-    FROM runtime_events request
-    JOIN runtime_events opening ON opening.invocation_id = request.invocation_id
+    FROM accounting_events request
+    JOIN accounting_events opening ON opening.invocation_id = request.invocation_id
         AND opening.kind = 'invocation_opened'
-    LEFT JOIN runtime_events completed ON completed.operation_id = request.operation_id
+    LEFT JOIN accounting_events completed ON completed.operation_id = request.operation_id
         AND completed.kind = 'model_completed'
-    LEFT JOIN runtime_events interrupted ON interrupted.operation_id = request.operation_id
+    LEFT JOIN accounting_events interrupted ON interrupted.operation_id = request.operation_id
         AND interrupted.kind = 'model_interrupted'
-    LEFT JOIN runtime_events terminal ON terminal.invocation_id = request.invocation_id
+    LEFT JOIN accounting_events terminal ON terminal.invocation_id = request.invocation_id
         AND terminal.kind = 'invocation_ended'
     WHERE request.kind = 'model_requested'
 )
@@ -414,7 +438,7 @@ FROM event_log started
 LEFT JOIN event_log settled ON settled.kind = 'auxiliary_model_settled'
     AND json_extract(settled.event_json, '$.request_id') = started.event_id
 LEFT JOIN event_log observed ON observed.kind = 'auxiliary_model_usage'
-    AND json_extract(observed.event_json, '$.request_id') = started.event_id
+    AND json_extract(COALESCE(observed.event_json, observed.retained_json), '$.request_id') = started.event_id
 WHERE started.kind = 'auxiliary_model_started';
 
 CREATE VIEW model_usage AS
@@ -444,12 +468,12 @@ SELECT request.sequence, request.event_id,
         'outcome', CASE json_extract(settled.accounting_tool, '$.outcome')
             WHEN 'succeeded' THEN 'success' WHEN 'failed' THEN 'error' ELSE 'unknown' END)
     END AS result
-FROM runtime_events request
-JOIN runtime_events opening ON opening.invocation_id = request.invocation_id
+FROM accounting_events request
+JOIN accounting_events opening ON opening.invocation_id = request.invocation_id
     AND opening.kind = 'invocation_opened'
-LEFT JOIN runtime_events settled ON settled.invocation_id = request.invocation_id
+LEFT JOIN accounting_events settled ON settled.invocation_id = request.invocation_id
     AND settled.operation_id = request.operation_id AND settled.kind = 'tool_settled'
-LEFT JOIN runtime_events terminal ON terminal.invocation_id = request.invocation_id
+LEFT JOIN accounting_events terminal ON terminal.invocation_id = request.invocation_id
     AND terminal.kind = 'invocation_ended'
 WHERE request.kind IN ('tool_dispatched', 'tool_rejected');
 
diff --git a/crates/event-log/src/lib.rs b/crates/event-log/src/lib.rs
index 19b72162d2..65f2254b5d 100644
--- a/crates/event-log/src/lib.rs
+++ b/crates/event-log/src/lib.rs
@@ -100,6 +100,8 @@ pub enum StoreError {
     InvalidTransition(String),
     #[error("prefix exceeds the requested event or byte limit")]
     PrefixTooLarge,
+    #[error("conversation material in this scope was collected after Session removal")]
+    MaterialCollected,
     #[error("derived transcript identity conflicts with its committed bytes")]
     TranscriptConflict,
     #[error(transparent)]
diff --git a/crates/event-log/src/prefix.rs b/crates/event-log/src/prefix.rs
index d30f95d4b2..f5be7642d3 100644
--- a/crates/event-log/src/prefix.rs
+++ b/crates/event-log/src/prefix.rs
@@ -49,6 +49,11 @@ impl EventLog {
         self.connection.run(move |connection| Box::pin(async move {
         let mut transaction = connection.begin().await?;
         let selection = Selection::resolve(&mut transaction, &scope).await?;
+        let collected: bool = sqlx::query_scalar(
+            "SELECT EXISTS(SELECT 1 FROM event_log WHERE invocation_id IS NOT NULL
+             AND event_json IS NULL AND (?1 IS NULL OR event_session=?1))"
+        ).bind(&selection.session).fetch_one(&mut *transaction).await?;
+        if collected { return Err(StoreError::MaterialCollected); }
         let lineage = Selection::predicate("runtime_events", "?2");
         let source = if selection.session.is_some() { "session_history_events" } else { "runtime_events" };
         let session_filter = if selection.session.is_some() {
diff --git a/crates/event-log/src/schema.rs b/crates/event-log/src/schema.rs
index 69f27ab066..e4998c193d 100644
--- a/crates/event-log/src/schema.rs
+++ b/crates/event-log/src/schema.rs
@@ -60,6 +60,13 @@ pub(crate) async fn initialize_connection(
             return Err(StoreError::UnsupportedDatabase);
         }
     }
+    // A fresh file needs pointer-map pages before SQLx creates its first table.
+    // Reclamation can then return space in small steps without a full VACUUM.
+    if tables == 0 {
+        sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
+            .execute(&mut *connection)
+            .await?;
+    }
     // Establish Rust identity before SQLx creates its migration ledger, so a
     // crash before migration 1 is distinguishable from an unrelated database.
     if application_id == 0 {
diff --git a/crates/event-log/src/sessions.rs b/crates/event-log/src/sessions.rs
index 6bbde2cd48..ca7a64a0e1 100644
--- a/crates/event-log/src/sessions.rs
+++ b/crates/event-log/src/sessions.rs
@@ -25,6 +25,8 @@ pub(crate) use admission::retain;
 pub(crate) mod copy;
 pub use copy::{AbandonRevision, SessionCopy, SessionCopyResult};
 mod execution;
+mod material;
+pub use material::MaterialCollection;
 mod metadata;
 mod origin;
 mod processes;
diff --git a/crates/event-log/src/sessions/material.rs b/crates/event-log/src/sessions/material.rs
new file mode 100644
index 0000000000..bc457a0c2f
--- /dev/null
+++ b/crates/event-log/src/sessions/material.rs
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use crate::{EventLog, StoreError};
+use maka_runtime::tool_call::{RejectionKind, ToolRejection};
+use maka_runtime::{
+    artifact::content_digest,
+    event::{Fact, InvocationOutcome, RuntimeEvent, ToolOutcome},
+    model::ModelEvent,
+};
+use serde_json::{Value, json};
+use sqlx::Connection;
+
+// Walk only reverse references of one retired Session, including archive writers.
+// UNION deduplicates diamonds; active owners short-circuit the search.
+const RETAINED: &str = "
+WITH RECURSIVE owners(id) AS (
+    VALUES(?1)
+    UNION
+    SELECT h.session_id FROM owners o
+      JOIN event_log e ON e.event_session=o.id
+      JOIN session_history_members h ON h.sequence=e.sequence
+    UNION
+    SELECT h.session_id FROM owners o
+      JOIN event_log e ON e.event_session=o.id
+      JOIN session_history_members h ON h.archive_sequence=e.sequence
+    UNION
+    SELECT h.session_id FROM owners o
+      JOIN event_log e ON e.event_session=o.id
+      JOIN session_revision_sources h ON h.sequence=e.sequence
+)
+SELECT EXISTS(SELECT 1 FROM owners JOIN session_control ON session_control.id=owners.id)";
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum MaterialCollection {
+    Done,
+    Retained(String),
+    Collected,
+}
+
+impl EventLog {
+    /// One Session and at most eight bodies per writer job. Advance the scan
+    /// past Retained Sessions; retry the same cursor after Collected.
+    /// Proof, payload retirement and body removal share the canonical writer.
+    pub async fn collect_session_material(
+        &self,
+        after: Option<&str>,
+    ) -> Result<MaterialCollection, StoreError> {
+        self.validate_root()?;
+        let after = after.map(str::to_owned);
+        self.connection.run(move |connection| Box::pin(async move {
+            let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+            let session: Option<String> = sqlx::query_scalar(
+                "SELECT session_id FROM session_retirements r WHERE remove_session=1 AND completed=1
+                 AND (?1 IS NULL OR session_id>?1) AND EXISTS (SELECT 1 FROM event_log e
+                     WHERE e.event_session=r.session_id AND e.event_json IS NOT NULL)
+                 ORDER BY session_id LIMIT 1"
+            ).bind(after).fetch_optional(&mut *tx).await?;
+            let Some(session) = session else {
+                tx.rollback().await?;
+                let free: i64 = sqlx::query_scalar("PRAGMA freelist_count").fetch_one(&mut *connection).await?;
+                if free == 0 { return Ok(MaterialCollection::Done); }
+                let incremental: i64 = sqlx::query_scalar("PRAGMA auto_vacuum").fetch_one(&mut *connection).await?;
+                if incremental != 2 { return Err(StoreError::UnsupportedDatabase); }
+                sqlx::query("PRAGMA incremental_vacuum(128)").execute(connection).await?;
+                return Ok(MaterialCollection::Collected);
+            };
+            // A previously collected body proves this owner set was already
+            // released. All owners were removed, so none can publish new copies.
+            let released: bool = sqlx::query_scalar(
+                "SELECT EXISTS(SELECT 1 FROM event_log WHERE event_session=? AND event_json IS NULL)"
+            ).bind(&session).fetch_one(&mut *tx).await?;
+            if !released && sqlx::query_scalar::<_, bool>(RETAINED)
+                .bind(&session).fetch_one(&mut *tx).await?
+            {
+                return Ok(MaterialCollection::Retained(session));
+            }
+            let rows: Vec<(String, String)> = sqlx::query_as(
+                "SELECT event_id, event_json FROM event_log WHERE event_session=?
+                 AND event_json IS NOT NULL ORDER BY sequence LIMIT 8"
+            ).bind(session).fetch_all(&mut *tx).await?;
+            for (id, body) in rows {
+                let event: RuntimeEvent = serde_json::from_str(&body)?;
+                let proof = retained(&event);
+                sqlx::query("DELETE FROM tool_result_payloads WHERE event_id=?")
+                    .bind(&id).execute(&mut *tx).await?;
+                let surface: Option<String> = sqlx::query_scalar(
+                    "DELETE FROM model_request_compositions WHERE event_id=? RETURNING digest"
+                ).bind(&id).fetch_optional(&mut *tx).await?;
+                if let Some(surface) = surface {
+                    sqlx::query("DELETE FROM request_compositions WHERE digest=?1
+                        AND NOT EXISTS (SELECT 1 FROM model_request_compositions WHERE digest=?1)")
+                        .bind(surface).execute(&mut *tx).await?;
+                }
+                sqlx::query("UPDATE event_log SET retained_json=?, body_digest=?, event_json=NULL WHERE event_id=?")
+                    .bind(serde_json::to_string(&proof)?).bind(content_digest(body.as_bytes()))
+                    .bind(&id).execute(&mut *tx).await?;
+            }
+            tx.commit().await.map_err(StoreError::CommitUnknown)?;
+            Ok(MaterialCollection::Collected)
+        })).await
+    }
+}
+
+/// Minimal original fields used by accounting and terminal proof. This is not
+/// a RuntimeEvent and must never enter replay, input projection or recovery.
+fn retained(event: &RuntimeEvent) -> Value {
+    let fact = match &event.fact {
+        Fact::InvocationOpened { configuration, .. } => json!({
+            "configuration": configuration.as_ref().map(|config| json!({"model": config.model}))
+        }),
+        Fact::ModelRequested {
+            step_id,
+            model_id,
+            purpose,
+            ..
+        } => json!({"step_id":step_id,"model_id":model_id,"purpose":purpose}),
+        Fact::ModelCompleted { step_id, output } => {
+            json!({"step_id":step_id,"output":{"usage":output.usage}})
+        }
+        Fact::ModelInterrupted { step_id, status } => json!({"step_id":step_id,"status":status}),
+        Fact::ModelObserved {
+            step_id,
+            event: ModelEvent::Finished { usage, .. },
+        } => json!({"step_id":step_id,"event":{"kind":"finished","data":{"usage":usage}}}),
+        Fact::ToolDispatched { call, name, .. } => json!({"call":call,"name":name}),
+        Fact::ToolRejected {
+            call, name, reason, ..
+        } => {
+            let kind = match reason {
+                ToolRejection::Unavailable => RejectionKind::Unavailable,
+                ToolRejection::InvalidInput { .. } => RejectionKind::InvalidInput,
+                ToolRejection::PolicyDenied { .. } => RejectionKind::PolicyDenied,
+                ToolRejection::PreparationFailed { .. } => RejectionKind::PreparationFailed,
+                ToolRejection::ExclusiveConflict => RejectionKind::ExclusiveConflict,
+                ToolRejection::Cancelled => RejectionKind::Cancelled,
+            };
+            json!({"call":call,"name":name,"reason":{"kind":kind}})
+        }
+        Fact::ToolSettled { outcome, .. } => json!({"outcome":{"kind":match outcome {
+            ToolOutcome::Succeeded { .. } => "succeeded",
+            ToolOutcome::Failed { .. } => "failed",
+            ToolOutcome::Unknown { .. } => "unknown",
+        }}}),
+        Fact::InvocationEnded { outcome } => {
+            let outcome = match outcome {
+                InvocationOutcome::Completed => json!({"kind":"completed"}),
+                InvocationOutcome::Failed { class, .. } => json!({"kind":"failed","class":class}),
+                InvocationOutcome::Cancelled { source } => {
+                    json!({"kind":"cancelled","source":source})
+                }
+                InvocationOutcome::HandoffPaused { pause } => {
+                    json!({"kind":"handoff_paused","pause":{"intent":pause.intent}})
+                }
+                InvocationOutcome::ContextCompactFinished { outcome } => {
+                    json!({"kind":"context_compact_finished","outcome":outcome})
+                }
+            };
+            json!({"outcome":outcome})
+        }
+        Fact::ExecutorStarted { .. }
+        | Fact::ExecutorObserved { .. }
+        | Fact::ExecutorCompleted { .. }
+        | Fact::MessageSteered { .. }
+        | Fact::ContextCheckpointRecorded { .. }
+        | Fact::ToolResultArchived { .. }
+        | Fact::ModelObserved { .. } => json!({}),
+    };
+    json!({"id":event.id,"invocation":event.invocation,"recorded_at":event.recorded_at,"fact":fact})
+}
diff --git a/crates/event-log/tests/archive.rs b/crates/event-log/tests/archive.rs
index 25709b694a..95c1e12459 100644
--- a/crates/event-log/tests/archive.rs
+++ b/crates/event-log/tests/archive.rs
@@ -784,6 +784,10 @@ async fn inherited_results_prune_and_compact_without_changing_siblings_or_frozen
         log.finish_session_retirement("branch").await.unwrap(),
         maka_event_log::sessions::SessionRetirement::Removed
     );
+    assert_eq!(
+        log.collect_session_material(None).await.unwrap(),
+        maka_event_log::sessions::MaterialCollection::Retained("branch".into())
+    );
     assert_eq!(
         log.read_model_context("nested", None, 100, 65536)
             .await
diff --git a/crates/event-log/tests/session_copy.rs b/crates/event-log/tests/session_copy.rs
index af1e298244..12fbc17bee 100644
--- a/crates/event-log/tests/session_copy.rs
+++ b/crates/event-log/tests/session_copy.rs
@@ -565,6 +565,11 @@ async fn copies_own_history_and_files_without_replaying_execution_across_retries
         log.finish_session_retirement("source").await.unwrap(),
         maka_event_log::sessions::SessionRetirement::Removed
     );
+    assert_eq!(
+        log.collect_session_material(None).await.unwrap(),
+        maka_event_log::sessions::MaterialCollection::Retained("source".into()),
+        "live descendants retain source proof"
+    );
     log.close().await.unwrap();
     let log = EventLog::open(&path).await.unwrap();
     assert_eq!(
diff --git a/crates/event-log/tests/session_material.rs b/crates/event-log/tests/session_material.rs
new file mode 100644
index 0000000000..b27096266e
--- /dev/null
+++ b/crates/event-log/tests/session_material.rs
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use maka_event_log::{
+    EventLog, StoreError,
+    sessions::{SessionCopy, SessionRetirement},
+};
+use maka_runtime::{
+    event::{Fact, InvocationInput, InvocationOutcome, LogScope},
+    model::{ModelEvent, ModelFinishReason, ModelUsage},
+    session::CopyPurpose,
+};
+use serde_json::{Value, json};
+
+#[path = "usage/fixture.rs"]
+mod fixture;
+use fixture::{event, query, request};
+
+#[tokio::test]
+async fn collection_waits_for_the_last_history_owner_and_preserves_frozen_accounting() {
+    let directory = tempfile::tempdir().unwrap();
+    let path = directory.path().join("material.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    log.create_session("source", "create", &json!({}), 1)
+        .await
+        .unwrap();
+    let opening = event(
+        Fact::InvocationOpened {
+            configuration: None,
+            input: InvocationInput::Message {
+                content: "private-question".repeat(10000).into(),
+                source_messages: vec![],
+                request_fingerprint: None,
+            },
+        },
+        0,
+    );
+    log.append(&opening).await.unwrap();
+    log.append(&request("observed", 1000)).await.unwrap();
+    let mut frozen = query();
+    frozen.through = Some(log.model_attempts(query(), 0, 100).await.unwrap().through);
+    for i in 0..12 {
+        log.append(&event(
+            Fact::ModelObserved {
+                step_id: "observed".into(),
+                event: ModelEvent::PartDelta {
+                    id: "part".into(),
+                    text: "private-response".repeat(1000),
+                    provider_options: None,
+                },
+            },
+            1100 + i,
+        ))
+        .await
+        .unwrap();
+    }
+    log.append(&event(
+        Fact::ModelObserved {
+            step_id: "observed".into(),
+            event: ModelEvent::Finished {
+                reason: ModelFinishReason::Stop,
+                usage: ModelUsage {
+                    input_tokens: Some(100),
+                    output_tokens: Some(12),
+                    ..Default::default()
+                },
+                provider_options: None,
+            },
+        },
+        1200,
+    ))
+    .await
+    .unwrap();
+    log.append(&event(
+        Fact::ModelInterrupted {
+            step_id: "observed".into(),
+            status: maka_runtime::event::ModelInterruption::Failed,
+        },
+        1250,
+    ))
+    .await
+    .unwrap();
+    // Provider usage is factual even when no response was accepted.
+    log.append(&event(
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::Failed {
+                class: "connection_lost".into(),
+                message: None,
+            },
+        },
+        1300,
+    ))
+    .await
+    .unwrap();
+    let before = log.usage_summary(query()).await.unwrap().summary;
+    let frozen_before = log.usage_summary(frozen.clone()).await.unwrap().summary;
+    let revision = log
+        .get_session::<Value>("source")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    log.copy_session(
+        SessionCopy {
+            source_session_id: "source".into(),
+            target_session_id: "copy".into(),
+            expected_source_revision: revision,
+            purpose: CopyPurpose::Branch {
+                turn_id: None,
+                side_conversation: false,
+            },
+        },
+        &json!({}),
+        2,
+    )
+    .await
+    .unwrap();
+    let inherited = log
+        .scoped_prefix(LogScope::Session { id: "copy".into() }, 100, 1_000_000)
+        .await
+        .unwrap()
+        .digest;
+    retire(&log, "source").await;
+    assert_eq!(
+        log.collect_session_material(None).await.unwrap(),
+        maka_event_log::sessions::MaterialCollection::Retained("source".into())
+    );
+    assert_eq!(
+        log.scoped_prefix(LogScope::Session { id: "copy".into() }, 100, 1_000_000)
+            .await
+            .unwrap()
+            .digest,
+        inherited
+    );
+    retire(&log, "copy").await;
+    let inspect = rusqlite::Connection::open(&path).unwrap();
+    let pages_before: i64 = inspect
+        .query_row("PRAGMA page_count", [], |row| row.get(0))
+        .unwrap();
+    drop(inspect);
+    assert_eq!(
+        log.collect_session_material(None).await.unwrap(),
+        maka_event_log::sessions::MaterialCollection::Collected
+    );
+    assert_eq!(
+        log.usage_summary(query()).await.unwrap().summary,
+        before,
+        "partial collection keeps accounting exact"
+    );
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    while log.collect_session_material(None).await.unwrap()
+        == maka_event_log::sessions::MaterialCollection::Collected
+    {}
+    assert_eq!(log.usage_summary(query()).await.unwrap().summary, before);
+    assert_eq!(
+        log.usage_summary(frozen).await.unwrap().summary,
+        frozen_before
+    );
+    assert!(matches!(
+        log.prefix(100, 1_000_000).await,
+        Err(StoreError::MaterialCollected)
+    ));
+    assert!(log.unfinished_invocations(10).await.unwrap().is_empty());
+    assert!(
+        log.append(&opening).await.is_err(),
+        "collection cannot resurrect a retired invocation"
+    );
+    assert!(log.session_copy_receipt("copy").await.unwrap().is_some());
+    let inspect = rusqlite::Connection::open(&path).unwrap();
+    let (bodies, leaked, digest): (i64, i64, String) = inspect
+        .query_row(
+            "SELECT (SELECT COUNT(*) FROM event_log WHERE event_json IS NOT NULL),
+         (SELECT COUNT(*) FROM event_log WHERE retained_json LIKE '%private-%'),
+         (SELECT body_digest FROM event_log WHERE event_id=?1)",
+            [&opening.event().id],
+            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
+        )
+        .unwrap();
+    assert_eq!((bodies, leaked), (0, 0));
+    let pages_after: i64 = inspect
+        .query_row("PRAGMA page_count", [], |row| row.get(0))
+        .unwrap();
+    assert!(
+        pages_after < pages_before,
+        "collection returns database pages, not only reusable slots"
+    );
+    assert_eq!(
+        digest,
+        maka_runtime::artifact::content_digest(&serde_json::to_vec(opening.event()).unwrap())
+    );
+    log.close().await.unwrap();
+}
+
+async fn retire(log: &EventLog, session: &str) {
+    let revision = log
+        .get_session::<Value>(session)
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    log.begin_session_removal(session, revision).await.unwrap();
+    assert_eq!(
+        log.finish_session_retirement(session).await.unwrap(),
+        SessionRetirement::Removed
+    );
+}
diff --git a/crates/event-log/tests/tool_usage.rs b/crates/event-log/tests/tool_usage.rs
index 73193a5cfd..40f3f0fe44 100644
--- a/crates/event-log/tests/tool_usage.rs
+++ b/crates/event-log/tests/tool_usage.rs
@@ -35,6 +35,9 @@ async fn tool_accounting_preserves_dispatch_truth_and_snapshot_without_private_b
     let directory = tempfile::tempdir().unwrap();
     let path = directory.path().join("usage.sqlite");
     let log = EventLog::open(&path).await.unwrap();
+    log.create_session("source", "source", &json!({}), 1)
+        .await
+        .unwrap();
     log.append(&event(
         Fact::InvocationOpened {
             configuration: None,
@@ -306,6 +309,42 @@ async fn tool_accounting_preserves_dispatch_truth_and_snapshot_without_private_b
     );
     log.close().await.unwrap();
     let log = EventLog::open(&path).await.unwrap();
+    let revision = log
+        .get_session::<serde_json::Value>("source")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    log.begin_session_removal("source", revision).await.unwrap();
+    log.finish_session_retirement("source").await.unwrap();
+    assert_eq!(
+        log.collect_session_material(None).await.unwrap(),
+        maka_event_log::sessions::MaterialCollection::Collected
+    );
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    while log.collect_session_material(None).await.unwrap()
+        == maka_event_log::sessions::MaterialCollection::Collected
+    {}
+    assert!(matches!(
+        log.prefix(100, 1_000_000).await,
+        Err(maka_event_log::StoreError::MaterialCollected)
+    ));
+    let inspect = rusqlite::Connection::open(&path).unwrap();
+    let (bodies, payloads, leaked): (i64, i64, i64) = inspect
+        .query_row(
+            "SELECT (SELECT COUNT(*) FROM event_log WHERE event_json IS NOT NULL),
+         (SELECT COUNT(*) FROM tool_result_payloads),
+         (SELECT COUNT(*) FROM event_log WHERE retained_json LIKE '%private-tool-data%')",
+            [],
+            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
+        )
+        .unwrap();
+    assert_eq!((bodies, payloads, leaked), (0, 0, 0));
+    assert_eq!(
+        log.usage_summary(query()).await.unwrap().summary.tools,
+        totals
+    );
     assert_eq!(
         log.tool_attempts(query(), 0, 100).await.unwrap().attempts,
         page.attempts
diff --git a/crates/runtime-host/src/execution/plugins/root.rs b/crates/runtime-host/src/execution/plugins/root.rs
index d37f9413f3..1b28f7f4c3 100644
--- a/crates/runtime-host/src/execution/plugins/root.rs
+++ b/crates/runtime-host/src/execution/plugins/root.rs
@@ -246,6 +246,7 @@ impl BoundCommands {
             let result = match result {
                 Ok(maka_event_log::sessions::AbandonRevision::Abandoned) => {
                     grants.lock().unwrap().remove(&id);
+                    worker.request_removal_recovery();
                     worker.publish_session_change(&id).await;
                     Ok(RevisionDisposition::Abandoned)
                 }
diff --git a/crates/runtime-host/src/execution/removal.rs b/crates/runtime-host/src/execution/removal.rs
index 941d45f787..fc55c59c9d 100644
--- a/crates/runtime-host/src/execution/removal.rs
+++ b/crates/runtime-host/src/execution/removal.rs
@@ -150,6 +150,23 @@ impl Executions {
                 .await
                 .map_err(internal)?;
             if page.is_empty() {
+                // The same recovery owner drains small material batches. Copied
+                // history pins may defer collection until their last owner retires.
+                let mut cursor = None;
+                while self.accepting() {
+                    use maka_event_log::sessions::MaterialCollection;
+                    match self
+                        .log
+                        .collect_session_material(cursor.as_deref())
+                        .await
+                        .map_err(internal)?
+                    {
+                        MaterialCollection::Done => break,
+                        MaterialCollection::Retained(session) => cursor = Some(session),
+                        MaterialCollection::Collected => {}
+                    }
+                    tokio::task::yield_now().await;
+                }
                 return Ok(pending);
             }
             for session in page {
diff --git a/crates/runtime-host/src/server/sessions/copy.rs b/crates/runtime-host/src/server/sessions/copy.rs
index c9225f56a7..e9acfccc44 100644
--- a/crates/runtime-host/src/server/sessions/copy.rs
+++ b/crates/runtime-host/src/server/sessions/copy.rs
@@ -203,6 +203,7 @@ pub(super) async fn abandon(host: &Host, input: AbandonInput) -> Result<AbandonO
             .map_err(stored)?
         {
             AbandonRevision::Abandoned => {
+                host.executions.request_removal_recovery();
                 host.executions
                     .publish_session_change(&input.target_session_id)
                     .await;
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 9817e26f62..b90288ce78 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -92,7 +92,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped mixed model/tool activity paging, and shared native/plugin pricing query/CAS edits are implemented. Consistent summaries include complete bounded breakdowns and missing-data coverage; The Insights plugin provides settings reports, filtering, pagination, persisted views and rate editing; Session Inspector uses the public Session-bound Client slot. | **Plugin + Host.** Reports and rebui [...]
 | Background health | BackgroundTaskHealth process and endpoint checks. | **Plugin + Host.** Health interpretation and Tool in a plugin; Host exposes authorized resource observations and bounded probes. A stored PID is not resource ownership. |
 | Session lineage | Public Rust/JS history copying and source reads, native branch/revision create/abandon, independent inherited pruning, Desktop canonical-input editing and complete durable drafts are implemented. Regeneration is removed; edits use revision and resend. | **Host.** One transactional lineage and workspace authority; plugins request changes through commands, not private lineage storage. |
-| Session lifecycle | Native and public plugin removal/preview/receipt APIs, atomic family retirement and queued cancellation, restart cleanup and shared-worktree ownership are implemented. Unreferenced canonical-material reclamation and shared Session queries remain. | **Host.** Preserve referenced history and accepted receipts; unknown process cleanup must not delete its workspace. Shared queries require real collaboration authorization. |
+| Session lifecycle | Native and public plugin removal/preview/receipt APIs, atomic family retirement and queued cancellation, restart cleanup and shared-worktree ownership are implemented. Unreferenced conversation bodies, tool payloads and request surfaces are reclaimed in restartable batches while accounting, receipts and referenced history survive. Shared Session queries remain. | **Host.** Preserve referenced history and accepted receipts; unknown process cleanup must not delete its [...]
 | Session transfer | Bundle export/import and unified external catalog/source/import for Codex, Claude Code and OpenCode. | **Plugin + Host.** Source parsing/discovery can be adapters; Host owns bounded canonical import, identities, artifacts, provenance and atomic publication. Native bundle format remains a Host contract. Never import source event bytes as trusted execution authority. |
 | Runtime policy | Shell/external-agent consumers and ordinary named tool profiles. | **Split.** Shell launch policy and capability ceilings stay in Host. External-agent settings belong to their domain. Profiles contribute definitions; Host applies the intersection at admission and per-step capture. Storing settings alone is insufficient. |
 | Access / collaboration | Credential rotation prepare/revoke; principal revoke; collaboration access, invitation, grant revoke, principal rename/revoke; Turn-request create/query/decide/acknowledge/withdraw. | **Host.** Reuse credential and durable admission authority. Plugins may provide workflows/UI but cannot decide grants, bypass revocation or own canonical accepted Turn requests. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index bd753ebba6..d7ff69338b 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -82,7 +82,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型/工具混合活动分页,以及原生/插件共享的报价查询与 CAS 修改。一致快照汇总包含有界完整分组与缺失数据覆盖率;Insights 插件已提供设置报表、筛选、分页、视图持久化和报价编辑;Session Inspector 已使用公共 Session 作用域 Client 插槽。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
-| Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
+| Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用对话正文、工具载荷与请求表面已支持可恢复的分批回收,保留计量、回执及被引用历史;shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
 | Session 迁入迁出 | bundle 导入/导出;Codex、Claude Code、OpenCode 的统一外部 catalog/source/import。 | **插件 + Host。** 来源解析/发现可以作为 adapter;Host 拥有有界规范导入、身份、附件、来源记录和原子发布。原生 bundle 格式仍是 Host 合同,不能把外部事件字节直接作为可信执行权威。 |
 | Runtime policy | shell/external-agent 消费,普通 named tool profiles。 | **拆分。** shell 启动策略和能力上限留在 Host;外部 agent 设置由对应领域消费。Profile 提供定义,Host 在准入和每步捕获时取能力交集。只保存设置不算完成。 |
 | 接入/协作 | credential rotation prepare/revoke、principal revoke;collaboration access、邀请、grant revoke、principal rename/revoke;Turn-request create/query/decide/acknowledge/withdraw。 | **Host。** 复用凭据和持久化准入权威。插件可以提供流程/UI,但不能决定授权、绕过撤销或拥有规范的已接受 Turn request。 |
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index fded74bb80..4c6a95d917 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -350,8 +350,12 @@ over every directly removed member; history access alone is insufficient.
 
 Host drains accepted executions and processes before reclaiming owned worktrees, and
 keeps a shared checkout until its last owner retires. Unproven process cleanup leaves
-the workspace intact without blocking unrelated Sessions. Canonical facts and inherited
-archive proofs remain stored; removal is not secure erasure of historical data.
+the workspace intact without blocking unrelated Sessions. Once no surviving history owner
+needs a removed Session, background batches release its event bodies, tool payloads and
+unshared request surfaces. Original body digests, accounting and terminal proof, identities
+and accepted receipts remain. Inherited archive/checkpoint proofs stay pinned transitively;
+collected bodies cannot be replayed. Incremental vacuum returns unused pages in bounded
+steps; removal is not secure erasure of database/WAL backups.
 
 ## Conversation recall
 
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 028be30a77..3efe9432a9 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -264,8 +264,10 @@ Desktop 可预览并附加同一 Host 中另一会话的已提交文本快照。
 必须覆盖全部直接删除成员的权限;历史读取授权不等于删除授权。
 
 Host 等待已接受执行和进程清理后才回收托管 worktree,共享目录保留到最后一个所有者退出。
-不能确认进程清理时保留目录,不阻塞无关会话。规范事实与继承归档证明仍保存在日志中;
-删除会话不等于安全擦除历史数据。
+不能确认进程清理时保留目录,不阻塞无关会话。没有存活历史所有者引用已删除会话后,
+后台分批释放事件正文、工具载荷及无共享引用的请求表面,保留原始正文摘要、计量和终态证明、
+身份及已接受回执。继承的归档/checkpoint 证明沿引用关系保留;回收后的正文不能重放。
+增量 vacuum 分批归还空闲页面;删除不等于安全擦除数据库/WAL 的备份。
 
 ## 会话回忆
 


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org