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 09:34:03 UTC

(maka) branch feat/runtime-host-rust updated (db1fbe984a -> 7d4a243639)

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

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


    from db1fbe984a feat(event-log): prune inherited session history independently
     new a17237fd96 feat(session): retire families with durable scoped receipts
     new 4b857748f4 feat(usage): persist and project physical model accounting
     new defd2ed756 feat(pricing): add revisioned native rate catalog
     new 7aad3022a8 feat(usage): freeze per-request pricing and durable valuation
     new b2d13184f9 perf(usage): pin accounting reads and avoid full response scans
     new ec52962227 feat(plugins): expose authorized physical model accounting
     new bb7f39ac96 feat(plugins): share native pricing control through public capabilities
     new d36945e658 feat(usage): expose canonical mixed model and tool activity
     new 7d4a243639 feat(usage): publish fixed-fence summaries with accounting coverage

The 9 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../runtime-host-client-operations.test.ts         |  21 +-
 .../src/main/plugin-authorization-dialog.ts        |   2 +
 apps/desktop/src/main/runtime-host-client.ts       |  34 +-
 crates/agent/src/lib.rs                            |  23 ++
 crates/agent/src/model_attempt.rs                  |   9 +-
 .../agent/src/pricing.rs                           |  13 +-
 crates/agent/src/runner.rs                         |   5 +-
 .../agent/tests/integration/code_mode_lifetime.rs  |   1 +
 crates/agent/tests/integration/lifetime.rs         |   1 +
 .../agent/tests/integration/model_interruption.rs  |   1 +
 crates/agent/tests/integration/startup_recovery.rs |   1 +
 .../agent/tests/integration/support/agent_loop.rs  |   1 +
 .../agent/tests/integration/support/code_mode.rs   |   1 +
 crates/agent/tests/integration/support/context.rs  |   1 +
 .../tests/integration/support/image_projection.rs  |   1 +
 crates/config/build.rs                             |   1 +
 crates/config/migrations/0001_configuration.sql    |  11 +
 crates/config/src/database.rs                      |   1 +
 crates/config/src/lib.rs                           |   1 +
 crates/config/src/pricing.rs                       | 175 +++++++++++
 crates/config/src/pricing/catalog.rs               |  92 ++++++
 crates/config/tests/pricing.rs                     | 252 +++++++++++++++
 crates/event-log/migrations/0001_runtime.sql       | 203 ++++++++++++
 crates/event-log/src/append.rs                     |  24 +-
 crates/event-log/src/effects.rs                    |   2 +-
 crates/event-log/src/handoff.rs                    |   2 +
 crates/event-log/src/interactions.rs               |   2 +
 crates/event-log/src/lib.rs                        |   3 +
 crates/event-log/src/message_admissions/insert.rs  |   2 +-
 crates/event-log/src/sessions.rs                   |  19 +-
 crates/event-log/src/sessions/admission.rs         |  50 +++
 crates/event-log/src/sessions/copy.rs              |   4 +
 crates/event-log/src/sessions/copy/lifecycle.rs    |  17 -
 crates/event-log/src/sessions/metadata.rs          |   6 +
 crates/event-log/src/sessions/origin.rs            |   2 +-
 crates/event-log/src/sessions/processes.rs         |  67 ++++
 crates/event-log/src/sessions/removal.rs           | 310 +++++++++++++++++++
 crates/event-log/src/sessions/removal/plan.rs      | 298 ++++++++++++++++++
 crates/event-log/src/shell_runs.rs                 |   2 +-
 crates/event-log/src/steering_delivery.rs          |   1 +
 crates/event-log/src/usage.rs                      | 180 +++++++++++
 crates/event-log/src/usage/auxiliary.rs            | 281 +++++++++++++++++
 crates/event-log/src/usage/reads.rs                | 139 +++++++++
 crates/event-log/src/usage/summary.rs              | 250 +++++++++++++++
 crates/event-log/src/usage/valuation.rs            | 118 +++++++
 crates/event-log/src/workspace.rs                  |  26 ++
 crates/event-log/tests/archive.rs                  |  88 ++++++
 crates/event-log/tests/auxiliary_usage.rs          | 179 +++++++++++
 crates/event-log/tests/message_queue.rs            | 187 +++++++++++
 crates/event-log/tests/session_copy.rs             | 343 ++++++++++++++++++++-
 crates/event-log/tests/tool_usage.rs               | 317 +++++++++++++++++++
 crates/event-log/tests/usage.rs                    | 275 +++++++++++++++++
 crates/event-log/tests/usage/fixture.rs            |  69 +++++
 crates/event-log/tests/usage_paging.rs             | 148 +++++++++
 crates/event-log/tests/usage_summary.rs            | 194 ++++++++++++
 crates/fs-tools/src/worktree.rs                    |   3 +
 crates/fs-tools/src/worktree/ownership.rs          |   2 +-
 crates/fs-tools/src/worktree/removal.rs            | 132 ++++++++
 crates/fs-tools/tests/worktree.rs                  |  60 ++++
 crates/js-runtime/src/plugin/sdk.js                |  13 +
 crates/plugins/src/authorization.rs                |  17 +
 crates/plugins/src/execution.rs                    |  19 ++
 .../shell.rs => plugins/src/execution/removal.rs}  |  47 +--
 crates/plugins/src/host.rs                         |   2 +
 crates/plugins/src/lib.rs                          |   2 +
 crates/plugins/src/{permissions.rs => pricing.rs}  |  32 +-
 crates/plugins/src/usage.rs                        |  84 +++++
 crates/protocol/src/authority.rs                   |   1 +
 crates/protocol/src/lib.rs                         |   1 +
 crates/protocol/src/operation.rs                   |   1 +
 crates/protocol/src/pricing.rs                     | 150 +++++++++
 crates/protocol/src/session.rs                     |  21 ++
 crates/runtime-host/src/execution.rs               |  19 +-
 crates/runtime-host/src/execution/compact.rs       |   1 +
 crates/runtime-host/src/execution/control.rs       |  56 ++++
 .../runtime-host/src/execution/handoff/prepare.rs  |   1 +
 crates/runtime-host/src/execution/plugins.rs       |  54 +++-
 .../runtime-host/src/execution/plugins/children.rs |  16 +-
 .../runtime-host/src/execution/plugins/effects.rs  |  44 ++-
 .../src/execution/plugins/effects/client.rs        |  24 +-
 .../src/execution/plugins/effects/http.rs          |   2 +-
 .../src/execution/plugins/effects/notification.rs  |  28 +-
 crates/runtime-host/src/execution/plugins/llm.rs   |  83 +----
 .../src/execution/plugins/llm/generate.rs          | 141 +++++++++
 .../runtime-host/src/execution/plugins/removal.rs  | 158 ++++++++++
 crates/runtime-host/src/execution/plugins/root.rs  |   4 +-
 crates/runtime-host/src/execution/plugins/usage.rs | 206 +++++++++++++
 .../src/execution/plugins/workspace.rs             |   2 +-
 crates/runtime-host/src/execution/prepare.rs       |   1 +
 crates/runtime-host/src/execution/pricing.rs       |  45 +++
 crates/runtime-host/src/execution/processes.rs     | 130 ++++++++
 crates/runtime-host/src/execution/provider.rs      |   2 +
 crates/runtime-host/src/execution/removal.rs       | 233 ++++++++++++++
 .../runtime-host/src/execution/resume/prepare.rs   |   1 +
 crates/runtime-host/src/execution/workspaces.rs    |  45 ++-
 crates/runtime-host/src/plugins.rs                 |   2 +
 crates/runtime-host/src/plugins/effects.rs         |   6 +-
 crates/runtime-host/src/plugins/host.rs            |   8 +
 .../runtime-host/src/plugins/javascript/bridge.rs  |  34 ++
 .../src/plugins/javascript/bridge/wire.rs          |  21 ++
 crates/runtime-host/src/plugins/pricing.rs         | 104 +++++++
 crates/runtime-host/src/plugins/process.rs         |  21 +-
 .../src/plugins/usage.rs}                          |  42 +--
 crates/runtime-host/src/server.rs                  |  11 +
 crates/runtime-host/src/server/authority.rs        |   5 +
 crates/runtime-host/src/server/dispatch.rs         |   3 +
 crates/runtime-host/src/server/interactions.rs     |   4 +
 crates/runtime-host/src/server/operations.rs       |  17 +
 .../src/server/plugin_authorization.rs             |   2 +
 crates/runtime-host/src/server/pricing.rs          |  76 +++++
 crates/runtime-host/src/server/sessions.rs         |  45 ++-
 crates/runtime-host/src/server/sessions/copy.rs    |   1 +
 crates/runtime-host/src/server/sessions/create.rs  |   1 +
 crates/runtime-host/src/server/sessions/removal.rs | 101 ++++++
 .../runtime-host/src/server/sessions/workspace.rs  |   3 +
 .../tests/fixtures/background-plugin.mjs           |  94 ++++++
 crates/runtime-host/tests/fixtures/host-plugin.mjs |  48 +++
 .../tests/integration/client_pricing.rs            | 166 ++++++++++
 .../integration/javascript_plugins/metering.rs     |  48 +++
 crates/runtime-host/tests/integration/main.rs      |   2 +
 .../tests/integration/plugin_authorization.rs      |  37 +++
 .../integration/plugin_authorization/pricing.rs    | 140 +++++++++
 .../integration/plugin_authorization/usage.rs      | 188 +++++++++++
 .../tests/integration/session_removal.rs           | 204 ++++++++++++
 crates/runtime/src/accounting.rs                   |  90 ++++++
 crates/runtime/src/accounting/activity.rs          |  64 ++++
 crates/runtime/src/accounting/summary.rs           | 107 +++++++
 crates/runtime/src/accounting/tool.rs              |  70 +++++
 crates/runtime/src/event.rs                        |   3 +
 crates/runtime/src/event_write.rs                  |  18 ++
 crates/runtime/src/lib.rs                          |   2 +
 crates/runtime/src/pricing.rs                      | 269 ++++++++++++++++
 crates/runtime/src/pricing/quote.rs                |  59 ++++
 crates/runtime/src/tool_call.rs                    |  12 +
 crates/runtime/src/tools/journal.rs                |   7 +-
 docs/rust-parity.md                                |   8 +-
 docs/rust-parity.zh-CN.md                          |   8 +-
 docs/rust-runtime.md                               |  26 ++
 docs/rust-runtime.zh-CN.md                         |  18 ++
 packages/plugin-sdk/README.md                      |  14 +
 packages/plugin-sdk/README.zh-CN.md                |  14 +
 packages/plugin-sdk/src/authorization.ts           |   4 +-
 packages/plugin-sdk/src/execution.ts               |  18 ++
 packages/plugin-sdk/src/host.ts                    |   6 +
 packages/plugin-sdk/src/pricing.ts                 |  67 ++++
 packages/plugin-sdk/src/usage.ts                   | 154 +++++++++
 packages/runtime-host/src/protocol/operations.ts   |   1 +
 .../src/protocol/plugin-authorization.ts           |  14 +-
 .../src/protocol/session-retirement.ts             |  48 +++
 .../src/server/operation-dispatcher.ts             |   5 +-
 scripts/rust/catalog-facts-entry.mjs               |   5 +
 scripts/rust/generate-catalog-facts.mjs            |  19 ++
 tests/fixtures/client-pricing-entry.mjs            | 120 +++++++
 tests/fixtures/client.mjs                          |   1 +
 154 files changed, 8725 insertions(+), 299 deletions(-)
 copy apps/desktop/src/renderer/features/connection-settings/generic-provider-mark.tsx => crates/agent/src/pricing.rs (70%)
 create mode 100644 crates/config/src/pricing.rs
 create mode 100644 crates/config/src/pricing/catalog.rs
 create mode 100644 crates/config/tests/pricing.rs
 create mode 100644 crates/event-log/src/sessions/admission.rs
 create mode 100644 crates/event-log/src/sessions/processes.rs
 create mode 100644 crates/event-log/src/sessions/removal.rs
 create mode 100644 crates/event-log/src/sessions/removal/plan.rs
 create mode 100644 crates/event-log/src/usage.rs
 create mode 100644 crates/event-log/src/usage/auxiliary.rs
 create mode 100644 crates/event-log/src/usage/reads.rs
 create mode 100644 crates/event-log/src/usage/summary.rs
 create mode 100644 crates/event-log/src/usage/valuation.rs
 create mode 100644 crates/event-log/tests/auxiliary_usage.rs
 create mode 100644 crates/event-log/tests/tool_usage.rs
 create mode 100644 crates/event-log/tests/usage.rs
 create mode 100644 crates/event-log/tests/usage/fixture.rs
 create mode 100644 crates/event-log/tests/usage_paging.rs
 create mode 100644 crates/event-log/tests/usage_summary.rs
 create mode 100644 crates/fs-tools/src/worktree/removal.rs
 copy crates/{presentation/src/shell.rs => plugins/src/execution/removal.rs} (53%)
 copy crates/plugins/src/{permissions.rs => pricing.rs} (54%)
 create mode 100644 crates/plugins/src/usage.rs
 create mode 100644 crates/protocol/src/pricing.rs
 create mode 100644 crates/runtime-host/src/execution/plugins/llm/generate.rs
 create mode 100644 crates/runtime-host/src/execution/plugins/removal.rs
 create mode 100644 crates/runtime-host/src/execution/plugins/usage.rs
 create mode 100644 crates/runtime-host/src/execution/pricing.rs
 create mode 100644 crates/runtime-host/src/execution/processes.rs
 create mode 100644 crates/runtime-host/src/execution/removal.rs
 create mode 100644 crates/runtime-host/src/plugins/pricing.rs
 copy crates/{agent/tests/integration/support/invocation.rs => runtime-host/src/plugins/usage.rs} (51%)
 create mode 100644 crates/runtime-host/src/server/pricing.rs
 create mode 100644 crates/runtime-host/src/server/sessions/removal.rs
 create mode 100644 crates/runtime-host/tests/integration/client_pricing.rs
 create mode 100644 crates/runtime-host/tests/integration/plugin_authorization/pricing.rs
 create mode 100644 crates/runtime-host/tests/integration/plugin_authorization/usage.rs
 create mode 100644 crates/runtime-host/tests/integration/session_removal.rs
 create mode 100644 crates/runtime/src/accounting.rs
 create mode 100644 crates/runtime/src/accounting/activity.rs
 create mode 100644 crates/runtime/src/accounting/summary.rs
 create mode 100644 crates/runtime/src/accounting/tool.rs
 create mode 100644 crates/runtime/src/pricing.rs
 create mode 100644 crates/runtime/src/pricing/quote.rs
 create mode 100644 packages/plugin-sdk/src/pricing.ts
 create mode 100644 packages/plugin-sdk/src/usage.ts
 create mode 100644 tests/fixtures/client-pricing-entry.mjs


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


(maka) 09/09: feat(usage): publish fixed-fence summaries with accounting coverage

Posted by wy...@apache.org.
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

commit 7d4a24363945308b9e8a2fafd2c31c7540502826
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 17:33:53 2026 +0800

    feat(usage): publish fixed-fence summaries with accounting coverage
    
    Generated-by: Codex
---
 crates/event-log/src/usage.rs                      |  28 +++
 crates/event-log/src/usage/reads.rs                |  20 +-
 crates/event-log/src/usage/summary.rs              | 250 +++++++++++++++++++++
 crates/event-log/tests/tool_usage.rs               |  16 ++
 crates/event-log/tests/usage_paging.rs             |   8 +-
 crates/event-log/tests/usage_summary.rs            | 194 ++++++++++++++++
 crates/js-runtime/src/plugin/sdk.js                |   5 +-
 crates/plugins/src/usage.rs                        |   4 +-
 crates/runtime-host/src/execution/plugins/usage.rs |  50 ++++-
 .../runtime-host/src/plugins/javascript/bridge.rs  |   4 +
 .../src/plugins/javascript/bridge/wire.rs          |   2 +
 crates/runtime-host/src/plugins/usage.rs           |  10 +-
 .../tests/fixtures/background-plugin.mjs           |   8 +-
 crates/runtime-host/tests/fixtures/host-plugin.mjs |   7 +
 .../integration/plugin_authorization/usage.rs      |   4 +
 crates/runtime/src/accounting.rs                   |   5 +
 crates/runtime/src/accounting/summary.rs           | 107 +++++++++
 docs/rust-parity.md                                |   4 +-
 docs/rust-parity.zh-CN.md                          |   4 +-
 docs/rust-runtime.md                               |   4 +-
 docs/rust-runtime.zh-CN.md                         |   4 +-
 packages/plugin-sdk/README.md                      |   2 +
 packages/plugin-sdk/README.zh-CN.md                |   2 +
 packages/plugin-sdk/src/usage.ts                   |  39 ++++
 24 files changed, 741 insertions(+), 40 deletions(-)

diff --git a/crates/event-log/src/usage.rs b/crates/event-log/src/usage.rs
index 133edca9c7..83f7fdef06 100644
--- a/crates/event-log/src/usage.rs
+++ b/crates/event-log/src/usage.rs
@@ -28,6 +28,8 @@ use sqlx::{Row, sqlite::SqliteRow};
 
 mod auxiliary;
 mod reads;
+mod summary;
+pub use summary::Report;
 pub(crate) mod valuation;
 
 #[derive(Clone, Debug)]
@@ -41,6 +43,32 @@ pub struct Query {
 }
 
 impl Query {
+    /// Capture inside the same read transaction as every section of the result.
+    async fn fence(&self, connection: &mut sqlx::SqliteConnection) -> Result<u64, StoreError> {
+        let current = crate::sequence_number(
+            sqlx::query_scalar("SELECT COALESCE(MAX(sequence), 0) FROM event_log")
+                .fetch_one(connection)
+                .await?,
+        )?;
+        let through = self.through.unwrap_or(current);
+        if through > current {
+            return Err(invalid("Usage fence is in the future"));
+        }
+        Ok(through)
+    }
+
+    fn restrict(&self, sql: &mut sqlx::QueryBuilder<sqlx::Sqlite>, through: u64) {
+        sql.push(" WHERE completed_at >= ")
+            .push_bind(self.from)
+            .push(" AND completed_at <= ")
+            .push_bind(self.to)
+            .push(" AND completed_sequence <= ")
+            .push_bind(through as i64);
+        if let Some(session) = &self.session_id {
+            sql.push(" AND session_id = ").push_bind(session.clone());
+        }
+    }
+
     fn validate(&self) -> Result<(), StoreError> {
         if !self.from.is_finite() || !self.to.is_finite() || self.from < 0.0 || self.to < self.from
         {
diff --git a/crates/event-log/src/usage/reads.rs b/crates/event-log/src/usage/reads.rs
index b0a4c44a14..300ee827d0 100644
--- a/crates/event-log/src/usage/reads.rs
+++ b/crates/event-log/src/usage/reads.rs
@@ -48,15 +48,7 @@ impl View {
     }
 
     fn restrict(&self, sql: &mut QueryBuilder<sqlx::Sqlite>, query: &Query, through: u64) {
-        sql.push(" WHERE completed_at >= ")
-            .push_bind(query.from)
-            .push(" AND completed_at <= ")
-            .push_bind(query.to)
-            .push(" AND completed_sequence <= ")
-            .push_bind(through as i64);
-        if let Some(session) = &query.session_id {
-            sql.push(" AND session_id = ").push_bind(session.clone());
-        }
+        query.restrict(sql, through);
         if let Self::Activity(selection) = self {
             if let Some(kind) = selection.kind {
                 sql.push(" AND kind = ").push_bind(match kind {
@@ -99,15 +91,7 @@ pub(super) async fn page<T: Serialize + Send + 'static>(
         .run(move |connection| {
             Box::pin(async move {
                 let mut tx = connection.begin().await?;
-                let current = crate::sequence_number(
-                    sqlx::query_scalar("SELECT COALESCE(MAX(sequence), 0) FROM event_log")
-                        .fetch_one(&mut *tx)
-                        .await?,
-                )?;
-                let through = query.through.unwrap_or(current);
-                if through > current {
-                    return Err(invalid("Usage fence is in the future"));
-                }
+                let through = query.fence(&mut tx).await?;
                 // Only closed internal view/column identifiers are interpolated.
                 let mut count = QueryBuilder::new("SELECT COUNT(*) FROM ");
                 count.push(view.table());
diff --git a/crates/event-log/src/usage/summary.rs b/crates/event-log/src/usage/summary.rs
new file mode 100644
index 0000000000..93e2c63444
--- /dev/null
+++ b/crates/event-log/src/usage/summary.rs
@@ -0,0 +1,250 @@
+/*
+ * 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 super::{EventLog, Query, StoreError, invalid};
+use maka_runtime::accounting::{
+    Cost, ModelSummary, ModelTotals, Pending, ProviderSummary, Summary, Tokens, ToolSummary,
+    ToolTotals,
+};
+use sqlx::{Connection, QueryBuilder, Row, Sqlite, sqlite::SqliteRow};
+
+/// Same canonical fence as activity paging, without activity-only filters.
+pub struct Report {
+    pub through: u64,
+    pub summary: Summary,
+}
+
+impl EventLog {
+    pub async fn usage_summary(&self, query: Query) -> Result<Report, StoreError> {
+        self.validate_root()?;
+        query.validate()?;
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    let through = query.fence(&mut tx).await?;
+                    let mut models = QueryBuilder::<Sqlite>::new("SELECT ");
+                    models.push(MODELS);
+                    query.restrict(&mut models, through);
+                    let model_row = models.build().fetch_one(&mut *tx).await?;
+                    let mut tools = QueryBuilder::<Sqlite>::new("SELECT ");
+                    tools.push(TOOLS);
+                    query.restrict(&mut tools, through);
+                    let tool_row = tools.build().fetch_one(&mut *tx).await?;
+                    let summary = Summary {
+                        pending: Pending {
+                            models: pending(&mut tx, &query, through, "model_usage", "started_at")
+                                .await?,
+                            tools: pending(
+                                &mut tx,
+                                &query,
+                                through,
+                                "tool_usage",
+                                "json_extract(result, '$.startedAt')",
+                            )
+                            .await?,
+                        },
+                        models: read_models(&model_row)?,
+                        tools: read_tools(&tool_row)?,
+                        by_provider: groups(
+                            &mut tx,
+                            &query,
+                            through,
+                            MODELS,
+                            "json_extract(quote_json, '$.providerId')",
+                        )
+                        .await?
+                        .iter()
+                        .map(|row| {
+                            Ok(ProviderSummary {
+                                provider_id: row.try_get("key")?,
+                                totals: read_models(row)?,
+                            })
+                        })
+                        .collect::<Result<_, StoreError>>()?,
+                        by_model: groups(&mut tx, &query, through, MODELS, "model_id")
+                            .await?
+                            .iter()
+                            .map(|row| {
+                                Ok(ModelSummary {
+                                    model_id: row.try_get("key")?,
+                                    totals: read_models(row)?,
+                                })
+                            })
+                            .collect::<Result<_, StoreError>>()?,
+                        by_tool: groups(&mut tx, &query, through, TOOLS, "name")
+                            .await?
+                            .iter()
+                            .map(|row| {
+                                Ok(ToolSummary {
+                                    name: row.try_get("key")?,
+                                    totals: read_tools(row)?,
+                                })
+                            })
+                            .collect::<Result<_, StoreError>>()?,
+                    };
+                    if serde_json::to_vec(&summary)?.len() > 48 * 1024 {
+                        return Err(invalid(
+                            "Usage summary exceeds response capacity; narrow the time range",
+                        ));
+                    }
+                    tx.commit().await?;
+                    Ok(Report { through, summary })
+                })
+            })
+            .await
+    }
+}
+
+async fn groups(
+    connection: &mut sqlx::SqliteConnection,
+    query: &Query,
+    through: u64,
+    columns: &'static str,
+    key: &'static str,
+) -> Result<Vec<SqliteRow>, StoreError> {
+    let mut sql = QueryBuilder::<Sqlite>::new("SELECT ");
+    sql.push(key).push(" AS key, ").push(columns);
+    query.restrict(&mut sql, through);
+    sql.push(" GROUP BY key ORDER BY calls DESC, key LIMIT 129");
+    let rows = sql.build().fetch_all(connection).await?;
+    if rows.len() > 128 {
+        return Err(invalid(
+            "Usage breakdown exceeds 128 groups; narrow the time range",
+        ));
+    }
+    Ok(rows)
+}
+
+async fn pending(
+    connection: &mut sqlx::SqliteConnection,
+    query: &Query,
+    through: u64,
+    table: &'static str,
+    started: &'static str,
+) -> Result<u64, StoreError> {
+    let mut sql = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) AS calls FROM ");
+    sql.push(table)
+        .push(" WHERE ")
+        .push(started)
+        .push(" >= ")
+        .push_bind(query.from)
+        .push(" AND ")
+        .push(started)
+        .push(" <= ")
+        .push_bind(query.to)
+        .push(" AND sequence <= ")
+        .push_bind(through as i64)
+        .push(" AND (completed_sequence IS NULL OR completed_sequence > ")
+        .push_bind(through as i64)
+        .push(")");
+    if let Some(session) = &query.session_id {
+        sql.push(" AND session_id = ").push_bind(session.clone());
+    }
+    count(&sql.build().fetch_one(connection).await?, "calls")
+}
+
+const MODELS: &str = "COUNT(*) AS calls,
+    COALESCE(SUM(outcome = 'success'), 0) AS success,
+    COALESCE(SUM(outcome = 'error'), 0) AS error,
+    COALESCE(SUM(outcome = 'aborted'), 0) AS aborted,
+    COALESCE(SUM(outcome = 'unknown'), 0) AS unknown,
+    TOTAL(json_extract(usage, '$.input_tokens')) AS input,
+    COUNT(*) - COUNT(json_extract(usage, '$.input_tokens')) AS input_missing,
+    TOTAL(json_extract(usage, '$.output_tokens')) AS output,
+    COUNT(*) - COUNT(json_extract(usage, '$.output_tokens')) AS output_missing,
+    TOTAL(json_extract(usage, '$.cache_read_tokens')) AS cache_read,
+    COUNT(*) - COUNT(json_extract(usage, '$.cache_read_tokens')) AS cache_read_missing,
+    TOTAL(json_extract(usage, '$.cache_write_tokens')) AS cache_write,
+    COUNT(*) - COUNT(json_extract(usage, '$.cache_write_tokens')) AS cache_write_missing,
+    TOTAL(json_extract(usage, '$.reasoning_tokens')) AS reasoning,
+    COUNT(*) - COUNT(json_extract(usage, '$.reasoning_tokens')) AS reasoning_missing,
+    TOTAL(usd) AS usd,
+    COUNT(*) - COUNT(usd) AS unvalued,
+    COUNT(*) - COUNT(json_extract(quote_json, '$.pricing')) AS unpriced
+    FROM model_usage";
+
+const TOOLS: &str = "COUNT(*) AS calls,
+    COALESCE(SUM(json_extract(result, '$.outcome') = 'success'), 0) AS success,
+    COALESCE(SUM(json_extract(result, '$.outcome') = 'error'), 0) AS error,
+    COALESCE(SUM(json_extract(result, '$.outcome') = 'unknown'), 0) AS unknown,
+    COALESCE(SUM(json_extract(result, '$.kind') = 'rejected'), 0) AS rejected,
+    AVG(CASE WHEN json_extract(result, '$.outcome') IN ('success', 'error')
+        THEN MAX(completed_at - json_extract(result, '$.startedAt'), 0.0) END) AS latency
+    FROM tool_usage";
+
+fn read_models(row: &SqliteRow) -> Result<ModelTotals, StoreError> {
+    Ok(ModelTotals {
+        calls: count(row, "calls")?,
+        success: count(row, "success")?,
+        error: count(row, "error")?,
+        aborted: count(row, "aborted")?,
+        unknown: count(row, "unknown")?,
+        input: tokens(row, "input", "input_missing")?,
+        output: tokens(row, "output", "output_missing")?,
+        cache_read: tokens(row, "cache_read", "cache_read_missing")?,
+        cache_write: tokens(row, "cache_write", "cache_write_missing")?,
+        reasoning: tokens(row, "reasoning", "reasoning_missing")?,
+        cost: Cost {
+            known_usd: finite(row.try_get("usd")?)?,
+            unvalued: count(row, "unvalued")?,
+            unpriced: count(row, "unpriced")?,
+        },
+    })
+}
+fn read_tools(row: &SqliteRow) -> Result<ToolTotals, StoreError> {
+    Ok(ToolTotals {
+        calls: count(row, "calls")?,
+        success: count(row, "success")?,
+        error: count(row, "error")?,
+        unknown: count(row, "unknown")?,
+        rejected: count(row, "rejected")?,
+        mean_latency_ms: row
+            .try_get::<Option<f64>, _>("latency")?
+            .map(finite)
+            .transpose()?,
+    })
+}
+const MAX_NUMBER: u64 = (1 << 53) - 1;
+
+fn count(row: &SqliteRow, column: &str) -> Result<u64, StoreError> {
+    let value = crate::sequence_number(row.try_get(column)?)?;
+    if value > MAX_NUMBER {
+        return Err(invalid("Usage aggregate exceeds exact integer capacity"));
+    }
+    Ok(value)
+}
+fn tokens(row: &SqliteRow, value: &str, missing: &str) -> Result<Tokens, StoreError> {
+    // SQLite's TOTAL avoids signed-integer overflow on provider u64 counters.
+    // Only exact interoperable integer totals are exposed; overflow is not zero.
+    let known = finite(row.try_get(value)?)?;
+    if known > MAX_NUMBER as f64 || known.fract() != 0.0 {
+        return Err(invalid("Usage aggregate exceeds exact integer capacity"));
+    }
+    Ok(Tokens {
+        known: known as u64,
+        missing: count(row, missing)?,
+    })
+}
+fn finite(value: f64) -> Result<f64, StoreError> {
+    if !value.is_finite() || value < 0.0 {
+        return Err(invalid("Usage aggregate exceeds finite numeric capacity"));
+    }
+    Ok(value)
+}
diff --git a/crates/event-log/tests/tool_usage.rs b/crates/event-log/tests/tool_usage.rs
index 9ee7f32270..b7591eacf0 100644
--- a/crates/event-log/tests/tool_usage.rs
+++ b/crates/event-log/tests/tool_usage.rs
@@ -163,6 +163,22 @@ async fn tool_accounting_preserves_dispatch_truth_and_snapshot_without_private_b
     );
     let page = log.tool_attempts(query(), 0, 100).await.unwrap();
     assert_eq!(page.total, 6);
+    let totals = log.usage_summary(query()).await.unwrap().summary.tools;
+    assert_eq!(
+        (
+            totals.calls,
+            totals.success,
+            totals.error,
+            totals.unknown,
+            totals.rejected
+        ),
+        (6, 1, 1, 2, 2)
+    );
+    assert_eq!(
+        totals.mean_latency_ms,
+        Some(3.875),
+        "refusals and unknown effects do not invent execution latency"
+    );
     let find = |id: &str| {
         page.attempts
             .iter()
diff --git a/crates/event-log/tests/usage_paging.rs b/crates/event-log/tests/usage_paging.rs
index 4674bb5f0d..c3f8d0be9b 100644
--- a/crates/event-log/tests/usage_paging.rs
+++ b/crates/event-log/tests/usage_paging.rs
@@ -52,7 +52,7 @@ async fn accounting_pages_bound_bytes_without_dropping_records_or_reading_respon
         log.append_batch(&[
             event(Fact::ModelRequested {
                 step_id: format!("step-{i}"),
-                model_id: "model".repeat(400),
+                model_id: format!("{i}-{}", "model".repeat(400)),
                 purpose: ModelPurpose::Main,
                 source_scope: LogScope::Session { id: "usage".into() },
                 source_high_water: 0,
@@ -124,6 +124,12 @@ async fn accounting_pages_bound_bytes_without_dropping_records_or_reading_respon
         }
     }
     assert_eq!(seen.len(), 80);
+    assert!(
+        matches!(log.usage_summary(query).await,
+        Err(maka_event_log::StoreError::InvalidTransition(message))
+            if message.contains("response capacity")),
+        "oversized complete breakdowns fail rather than silently dropping groups"
+    );
     log.close().await.unwrap();
 }
 
diff --git a/crates/event-log/tests/usage_summary.rs b/crates/event-log/tests/usage_summary.rs
new file mode 100644
index 0000000000..16896eba33
--- /dev/null
+++ b/crates/event-log/tests/usage_summary.rs
@@ -0,0 +1,194 @@
+/*
+ * 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;
+use maka_runtime::{
+    accounting::Tokens,
+    event::{Fact, InvocationInput},
+    model::{ModelFinishReason, ModelStep, ModelUsage},
+    pricing::Quote,
+};
+use serde_json::json;
+#[path = "usage/fixture.rs"]
+mod fixture;
+use fixture::{event, query, request};
+
+#[tokio::test]
+async fn summary_keeps_missing_and_free_distinct_and_reuses_the_activity_fence() {
+    let directory = tempfile::tempdir().unwrap();
+    let path = directory.path().join("summary.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    let empty = log.usage_summary(query()).await.unwrap().summary;
+    assert_eq!(empty.models.calls, 0);
+    assert_eq!(
+        empty.models.input,
+        Tokens {
+            known: 0,
+            missing: 0
+        }
+    );
+    assert_eq!(empty.tools.mean_latency_ms, None);
+    log.append(&event(
+        Fact::InvocationOpened {
+            configuration: None,
+            input: InvocationInput::Message {
+                content: "fixture".into(),
+                source_messages: vec![],
+                request_fingerprint: None,
+            },
+        },
+        0,
+    ))
+    .await
+    .unwrap();
+    let priced: Quote = serde_json::from_value(json!({
+        "providerId":"fixture", "revision":1,
+        "pricing":{"modelKey":"fixture:model","inputUsdPer1M":1,"outputUsdPer1M":2}
+    }))
+    .unwrap();
+    let free: Quote = serde_json::from_value(json!({
+        "providerId":"fixture", "revision":2,
+        "pricing":{"modelKey":"fixture:model","inputUsdPer1M":0,"outputUsdPer1M":0}
+    }))
+    .unwrap();
+    for (id, quote, input, output) in [
+        ("free", Some(free), 1, Some(0)),
+        ("unpriced", None, 2, None),
+        ("partial", Some(priced.clone()), 3, None),
+    ] {
+        let mut admitted = request(id, 1000);
+        if let Some(quote) = quote {
+            admitted = admitted.with_quote(quote).unwrap();
+        }
+        log.append_batch(&[admitted, completed(id, input, output, 1250)])
+            .await
+            .unwrap();
+    }
+    log.append(&request("paid", 2000).with_quote(priced).unwrap())
+        .await
+        .unwrap();
+    let first = log.model_attempts(query(), 0, 100).await.unwrap();
+    let mut fixed = query();
+    fixed.through = Some(first.through);
+    log.append(&completed("paid", 1_000_000, Some(2_000_000), 2250))
+        .await
+        .unwrap();
+    let captured = log.usage_summary(fixed.clone()).await.unwrap();
+    assert_eq!(captured.through, first.through);
+    assert_eq!(captured.summary.models.calls, 3);
+    assert_eq!(captured.summary.pending.models, 1);
+    let current = log.usage_summary(query()).await.unwrap();
+    let models = &current.summary.models;
+    assert_eq!(current.summary.pending.models, 0);
+    assert_eq!(models.calls, 4);
+    assert_eq!(models.success, 4);
+    assert_eq!(
+        models.input,
+        Tokens {
+            known: 1_000_006,
+            missing: 0
+        }
+    );
+    assert_eq!(
+        models.output,
+        Tokens {
+            known: 2_000_000,
+            missing: 2
+        }
+    );
+    assert_eq!(
+        models.cache_read,
+        Tokens {
+            known: 0,
+            missing: 4
+        }
+    );
+    assert_eq!(models.cost.known_usd, 5.0);
+    assert_eq!(
+        models.cost.unvalued, 2,
+        "free is valued; incomplete usage is not"
+    );
+    assert_eq!(
+        models.cost.unpriced, 1,
+        "missing usage does not imply missing rates"
+    );
+    assert_eq!(current.summary.by_model.len(), 1);
+    assert_eq!(current.summary.by_model[0].totals, *models);
+    assert_eq!(current.summary.by_provider.len(), 2);
+    assert_eq!(
+        current.summary.by_provider[0].provider_id.as_deref(),
+        Some("fixture")
+    );
+    assert_eq!(current.summary.by_provider[0].totals.calls, 3);
+    assert_eq!(current.summary.by_provider[1].provider_id, None);
+    assert_eq!(current.summary.by_provider[1].totals.cost.unpriced, 1);
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    assert_eq!(
+        log.usage_summary(query()).await.unwrap().summary,
+        current.summary
+    );
+    assert_eq!(
+        log.usage_summary(fixed.clone()).await.unwrap().summary,
+        captured.summary
+    );
+
+    log.append_batch(&[
+        request("overflow", 20000),
+        completed("overflow", u64::MAX, Some(0), 21000),
+    ])
+    .await
+    .unwrap();
+    assert!(matches!(log.usage_summary(query()).await,
+        Err(maka_event_log::StoreError::InvalidTransition(message))
+            if message.contains("exact integer capacity")));
+    assert_eq!(
+        log.usage_summary(fixed).await.unwrap().summary,
+        captured.summary,
+        "out-of-snapshot records cannot overflow this snapshot"
+    );
+    log.close().await.unwrap();
+}
+
+fn completed(
+    id: &str,
+    input: u64,
+    output: Option<u64>,
+    micros: u64,
+) -> maka_runtime::event::EventWrite {
+    event(
+        Fact::ModelCompleted {
+            step_id: id.into(),
+            output: ModelStep {
+                parts: vec![],
+                finish_reason: ModelFinishReason::Stop,
+                usage: ModelUsage {
+                    input_tokens: Some(input),
+                    output_tokens: output,
+                    ..Default::default()
+                },
+                provider_options: None,
+                response_id: None,
+                model: None,
+                timestamp: None,
+            },
+        },
+        micros,
+    )
+}
diff --git a/crates/js-runtime/src/plugin/sdk.js b/crates/js-runtime/src/plugin/sdk.js
index a2eb622d44..a6e6c31ec4 100644
--- a/crates/js-runtime/src/plugin/sdk.js
+++ b/crates/js-runtime/src/plugin/sdk.js
@@ -302,7 +302,10 @@
       ...pricing,
       update: (input) => host('pricing.update', { authority, input }),
     }),
-    usage: Object.freeze({ activity: (input) => host('usage.activity', { authority, input }) }),
+    usage: Object.freeze({
+      activity: (input) => host('usage.activity', { authority, input }),
+      summary: (input) => host('usage.summary', { authority, input }),
+    }),
     sessions: Object.freeze({ list: (input = {}) => host('sessions.list', { authority, input }) }),
     history: Object.freeze({
       list: (input = {}) => host('history.list', { authority, input }),
diff --git a/crates/plugins/src/usage.rs b/crates/plugins/src/usage.rs
index 10bc27c982..f0820c2c77 100644
--- a/crates/plugins/src/usage.rs
+++ b/crates/plugins/src/usage.rs
@@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize};
 
 pub use maka_runtime::accounting::{
     Activity, ActivityKind, ActivityStatus, AuxiliarySource, ModelAttempt, Origin, Outcome,
-    Selection, ToolAttempt, ToolResult, ToolStatus,
+    Selection, Summary, ToolAttempt, ToolResult, ToolStatus,
 };
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -79,4 +79,6 @@ pub struct Page {
 pub trait Usage: Send + Sync {
     /// At most 100 rows / 48 KiB. Every continuation rechecks current authority.
     fn activity(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>>;
+    /// Headline totals for an activity cursor's range/scope/fence, not its list filters.
+    fn summary(&self, call: Scope, cursor: String) -> BoxFuture<'_, Result<Summary, CommandError>>;
 }
diff --git a/crates/runtime-host/src/execution/plugins/usage.rs b/crates/runtime-host/src/execution/plugins/usage.rs
index 5030ab0ce1..b6b3c48d8f 100644
--- a/crates/runtime-host/src/execution/plugins/usage.rs
+++ b/crates/runtime-host/src/execution/plugins/usage.rs
@@ -22,7 +22,7 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
 use maka_plugins::{
     authorization::{Boundary, Capability},
     call::Scope,
-    usage::{Filter, Page, Read},
+    usage::{Filter, Page, Read, Summary},
 };
 use serde::{Deserialize, Serialize};
 
@@ -72,6 +72,36 @@ fn invalid_cursor() -> Error {
 }
 
 impl Executions {
+    pub(crate) async fn plugin_usage_summary(
+        &self,
+        call: Scope,
+        cursor: String,
+    ) -> Result<Summary, Error> {
+        let cursor = Cursor::decode(&cursor, self.interactions.epoch())?;
+        self.check_usage_filter(&call, &cursor.filter).await?;
+        let report = self
+            .log
+            .usage_summary(maka_event_log::usage::Query {
+                from: cursor.filter.from,
+                to: cursor.filter.to,
+                session_id: cursor.filter.session_id.clone(),
+                through: Some(cursor.through),
+            })
+            .await
+            .map_err(usage_error)?;
+        self.check_usage_filter(&call, &cursor.filter).await?;
+        Ok(report.summary)
+    }
+
+    async fn check_usage_filter(&self, call: &Scope, filter: &Filter) -> Result<(), Error> {
+        let mut checked = filter.clone();
+        restrict(self.usage_boundary(call).await?, &mut checked)?;
+        if &checked != filter {
+            return Err(Error::Revoked);
+        }
+        Ok(())
+    }
+
     async fn usage_boundary(&self, call: &Scope) -> Result<Boundary, Error> {
         if call.identity.agent().is_some() {
             self.plugin_execution_boundary(call).await
@@ -113,16 +143,9 @@ impl Executions {
                 100,
             )
             .await
-            .map_err(|error| match error {
-                maka_event_log::StoreError::InvalidTransition(message) => Error::Invalid(message),
-                other => storage(other),
-            })?;
+            .map_err(usage_error)?;
         // Reading can yield to consent revocation or a Session boundary change.
-        let mut checked = filter.clone();
-        restrict(self.usage_boundary(&call).await?, &mut checked)?;
-        if checked != filter {
-            return Err(Error::Revoked);
-        }
+        self.check_usage_filter(&call, &filter).await?;
         if result.through > MAX_NUMBER || result.total > MAX_NUMBER || offset > result.total {
             return Err(invalid_cursor());
         }
@@ -174,3 +197,10 @@ fn restrict(boundary: Boundary, filter: &mut Filter) -> Result<(), Error> {
         Boundary::Workspace { .. } | Boundary::Directory { .. } => Err(Error::Denied),
     }
 }
+
+fn usage_error(error: maka_event_log::StoreError) -> Error {
+    match error {
+        maka_event_log::StoreError::InvalidTransition(message) => Error::Invalid(message),
+        other => storage(other),
+    }
+}
diff --git a/crates/runtime-host/src/plugins/javascript/bridge.rs b/crates/runtime-host/src/plugins/javascript/bridge.rs
index a950dfbb06..38c65106be 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge.rs
@@ -309,6 +309,10 @@ impl State {
                 let call = self.calls.get(&input.authority)?;
                 encode(self.usage.activity(call, input.input).await?)
             }
+            Request::UsageSummary(input) => {
+                let call = self.calls.get(&input.authority)?;
+                encode(self.usage.summary(call, input.input).await?)
+            }
             Request::PricingQuery(input) => encode(self.pricing.query(input).await?),
             Request::PricingUpdate(input) => {
                 let call = self.calls.get(&input.authority)?;
diff --git a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
index 1e407a5b99..ae36786760 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
@@ -79,6 +79,8 @@ pub(super) enum Request {
     HistoryRead(HistoryRead),
     #[serde(rename = "usage.activity")]
     UsageActivity(Authorized<maka_plugins::usage::Read>),
+    #[serde(rename = "usage.summary")]
+    UsageSummary(Authorized<String>),
     #[serde(rename = "pricing.query")]
     PricingQuery(maka_runtime::pricing::Query),
     #[serde(rename = "pricing.update")]
diff --git a/crates/runtime-host/src/plugins/usage.rs b/crates/runtime-host/src/plugins/usage.rs
index 9b3505c5af..fe7c866730 100644
--- a/crates/runtime-host/src/plugins/usage.rs
+++ b/crates/runtime-host/src/plugins/usage.rs
@@ -22,10 +22,18 @@ use futures_util::future::BoxFuture;
 use maka_plugins::{
     call::Scope,
     execution::CommandError,
-    usage::{Page, Read, Usage},
+    usage::{Page, Read, Summary, Usage},
 };
 
 impl Usage for Effects {
+    fn summary(&self, call: Scope, cursor: String) -> BoxFuture<'_, Result<Summary, CommandError>> {
+        Box::pin(async move {
+            let _lease = self.owner.admit().map_err(|_| CommandError::Revoked)?;
+            let host = self.host.upgrade().ok_or(CommandError::Draining)?;
+            host.plugin_usage_summary(call, cursor).await
+        })
+    }
+
     fn activity(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>> {
         Box::pin(async move {
             let _lease = self.owner.admit().map_err(|_| CommandError::Revoked)?;
diff --git a/crates/runtime-host/tests/fixtures/background-plugin.mjs b/crates/runtime-host/tests/fixtures/background-plugin.mjs
index 8d4e40fe8a..cb655cfea9 100644
--- a/crates/runtime-host/tests/fixtures/background-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/background-plugin.mjs
@@ -49,8 +49,12 @@ export default async function (ctx) {
       input.read
     );
     try {
-      const page = await ctx.withAuthorization(input.grant, (call) => call.usage.activity(read));
-      return JSON.parse(JSON.stringify({ page }));
+      const result = await ctx.withAuthorization(input.grant, async (call) => {
+        const page = await call.usage.activity(read);
+        const summary = await call.usage.summary(page.cursor);
+        return { page, summary };
+      });
+      return JSON.parse(JSON.stringify(result));
     } catch (error) {
       return { error: error.code };
     }
diff --git a/crates/runtime-host/tests/fixtures/host-plugin.mjs b/crates/runtime-host/tests/fixtures/host-plugin.mjs
index cabf92a53e..f9525fd5b0 100644
--- a/crates/runtime-host/tests/fixtures/host-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/host-plugin.mjs
@@ -457,6 +457,13 @@ export default async function (ctx) {
             )
           )
             throw new Error('Agent accounting lost usage or escaped its Session');
+          const summary = await call.usage.summary(usage.cursor);
+          if (
+            summary.models.calls === 0 ||
+            summary.models.input.known < 3 ||
+            summary.models.output.known < 5
+          )
+            throw new Error('Agent summary lost auxiliary model counters');
           try {
             await call.pricing.update({
               expectedRevision: rates.revision,
diff --git a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
index 29a432f0cb..0c87686970 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
@@ -92,6 +92,10 @@ impl Probe {
         )
         .await;
         assert_eq!(empty["page"]["total"], 0, "{empty}");
+        assert!(
+            empty["summary"]["models"]["calls"].as_u64().unwrap() > 0,
+            "activity filters must not erase headline usage: {empty}"
+        );
         let repeated = read(
             peer,
             client,
diff --git a/crates/runtime/src/accounting.rs b/crates/runtime/src/accounting.rs
index d71cb6dcbd..db77489848 100644
--- a/crates/runtime/src/accounting.rs
+++ b/crates/runtime/src/accounting.rs
@@ -23,8 +23,13 @@ use crate::{context::ModelPurpose, event::Invocation, execution::ModelBinding, m
 use serde::{Deserialize, Serialize};
 use uuid::Uuid;
 mod activity;
+mod summary;
 mod tool;
 pub use activity::{Activity, ActivityKind, ActivityStatus, Selection};
+pub use summary::{
+    Cost, ModelSummary, ModelTotals, Pending, ProviderSummary, Summary, Tokens, ToolSummary,
+    ToolTotals,
+};
 pub use tool::{ToolAttempt, ToolResult, ToolStatus};
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/runtime/src/accounting/summary.rs b/crates/runtime/src/accounting/summary.rs
new file mode 100644
index 0000000000..aff2540258
--- /dev/null
+++ b/crates/runtime/src/accounting/summary.rs
@@ -0,0 +1,107 @@
+/*
+ * 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 serde::{Deserialize, Serialize};
+
+/// Sum of reported counters, qualified by calls that omitted this counter.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Tokens {
+    pub known: u64,
+    pub missing: u64,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Cost {
+    pub known_usd: f64,
+    /// Calls without a persisted valuation; includes unpriced calls.
+    pub unvalued: u64,
+    pub unpriced: u64,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ModelTotals {
+    pub calls: u64,
+    pub success: u64,
+    pub error: u64,
+    pub aborted: u64,
+    pub unknown: u64,
+    pub input: Tokens,
+    pub output: Tokens,
+    pub cache_read: Tokens,
+    pub cache_write: Tokens,
+    pub reasoning: Tokens,
+    pub cost: Cost,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ToolTotals {
+    /// Includes pre-dispatch refusals, which are counted separately below.
+    pub calls: u64,
+    pub success: u64,
+    pub error: u64,
+    pub unknown: u64,
+    pub rejected: u64,
+    /// Only known success/error settlements contribute to observed execution latency.
+    pub mean_latency_ms: Option<f64>,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Summary {
+    pub models: ModelTotals,
+    pub tools: ToolTotals,
+    pub by_provider: Vec<ProviderSummary>,
+    pub by_model: Vec<ModelSummary>,
+    pub by_tool: Vec<ToolSummary>,
+    /// Unsettled admissions within the time range at this fence, not completed calls.
+    pub pending: Pending,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Pending {
+    pub models: u64,
+    pub tools: u64,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ProviderSummary {
+    /// Unknown identity is not inferred from a transport protocol or current settings.
+    pub provider_id: Option<String>,
+    pub totals: ModelTotals,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ModelSummary {
+    pub model_id: String,
+    pub totals: ModelTotals,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ToolSummary {
+    pub name: String,
+    pub totals: ToolTotals,
+}
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index b76447c01e..35bd045cba 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| 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. Aggregates and Usage screens remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| 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; Usage screens remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing us [...]
 | 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. |
@@ -112,7 +112,7 @@ Original TS plugins are not source-compatible with the new SDK.
 | Skills: tool publication | Skill/SkillSearch are ordinary Contributions, without a package-name privilege. Per-step bindings capture handlers and supporting context together, after tool ceilings; physical retries retain the same snapshot. |
 | WorkHub: precise execution commands | Typed commands carry stable operation IDs, exact targets and expected revisions. Correction freezes plugin intent before exact Host control/submission and atomically commits its business receipt; accepted work settles independently of plugin availability. Queue editing preserves the original submission proof. Plugins receive neither SQL transaction callbacks nor unrestricted execution handles. |
 | WorkHub / Graph / Plan: selectable behavior | Open `BehaviorId` selects a typed Contribution; Graph/Swarm register independently. Host acceptance covers a non-builtin business. Preserve Session defaults and durable per-Turn choices; a requested unavailable behavior fails explicitly. Behavior preparation and input preparation are separate contracts, not one hook bus. |
-| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Public Usage reads expose scoped model/tool attempts and refusals without conversation bodies. Aggregates and reporting remain. Domain catalog/mutation APIs can be typed plugin Services rather than new kernel methods. |
+| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Public Usage reads expose scoped model/tool attempts and refusals without conversation bodies. Fixed-fence summaries and bounded breakdowns are available; reporting UI remains. Domain catalog/mutation APIs can be typed plugin  [...]
 | Skills / WorkHub / default behavior: business UI and prompt context | Published Clients own the actual Skills picker/management and WorkHub surface through Slots and Remote. Native adapters validate the originating Host and document; connection replacement revokes old Remote leases without replay. Prompt Contributions own business instructions. Inactive features remain visibly unavailable without blocking ordinary chat. |
 | Other TS extension services | New SDK lacks equivalent public registrations/services for LSP routing, commands, Skills/Goals queries, shell environment contributions, Settings definitions, authorization flows and LLM adapter registration; questions/forms and source-input/attachment-copy contracts are public; permission approvals remain Host-owned. Implement domain registries as plugin services where possible, retaining Host authority for sensitive actions. `llm.generate` is not adapter [...]
 
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index 36995a15f6..6c696dadba 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型/工具混合活动分页,以及原生/插件共享的报价查询与 CAS 修改。聚合统计与 Usage 视图仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型/工具混合活动分页,以及原生/插件共享的报价查询与 CAS 修改。一致快照汇总包含有界完整分组与缺失数据覆盖率;Usage 视图仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
@@ -101,7 +101,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Skills:工具发布 | Skill/SkillSearch 是普通 Contribution,不享有包名特权。每步绑定在工具上限内共同捕获 handler 和支持上下文,物理重试保持原快照。 |
 | WorkHub:精确执行命令 | 类型化命令携带稳定操作 ID、精确目标及预期 revision。纠正先冻结插件意图,再精确控制/提交 Host 工作,最后原子记录业务回执;已接受工作不依赖插件可用性继续结算。队列编辑保留原提交凭证。插件不获得 SQL 事务回调或无限制执行句柄。 |
 | WorkHub/Graph/Plan:可选择的 behavior | 已用开放的 `BehaviorId` 选择类型化 Contribution,Graph/Swarm 独立注册;非内置业务已通过 Host 验收。保留 Session 默认值和持久单 Turn 选择;请求的 behavior 不可用时明确失败。behavior 准备与输入准备是独立契约,不合并为 hook 总线。 |
-| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。公共 Usage 读取提供作用域内的模型/工具物理调用和派发前拒绝,不暴露对话正文;聚合与报表仍待完成。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
+| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。公共 Usage 读取提供作用域内的模型/工具物理调用和派发前拒绝,不暴露对话正文;固定快照汇总与有界分组已提供;报表界面仍待完成。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
 | Skills/WorkHub/默认行为:业务 UI 与 Prompt 上下文 | 发布的 Client 通过 Slot 和 Remote 拥有真实 Skills 选择器/管理页及 WorkHub 界面。原生适配验证原 Host 与 document;连接换代撤销旧 Remote 租约,不重放调用。Prompt Contribution 拥有业务指令。功能停用明确显示不可用,不阻塞普通聊天。 |
 | 其余 TS 扩展服务 | 新 SDK 尚缺等价的公开 LSP 路由、Commands、Skills/Goals 查询、shell 环境变量 Contribution、Settings 定义、授权流程及 LLM adapter 注册;问题/表单、源输入与附件复制已有公共契约;权限审批仍归 Host。能由插件服务实现的领域注册放在插件侧,敏感行为权威仍归 Host。`llm.generate` 不等于 adapter 注册。 |
 
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 55d1c62d60..fded74bb80 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -223,7 +223,9 @@ user/project content paths stay separate from private journals.
   with reported usage and survive rejection, edits and recovery. Public Rust/JS Usage reads
   recheck scoped authorization and return at most 100 rows / 48 KiB under a fixed settlement
   fence, with one filtered activity page across model calls, tool effects and refusals.
-  Continuation cursors expire on Host restart. Aggregates and Usage screens are not yet connected.
+  Continuation cursors expire on Host restart. Summaries share the activity fence, preserve missing-counter/
+  price coverage, and bound complete provider/model/tool breakdowns. Pending admissions remain separate.
+  Usage screens are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 96fed7bc38..028be30a77 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -172,7 +172,9 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
   Rust/JS 插件复用同一报价与修改路径;改价需要 profile 范围的明确授权,Agent 调用本身不授予该权限。
   每次准入冻结公共提供商身份和报价,用量与估价一起提交;回复被拒绝、改价或恢复都不改写历史金额。
   公共 Rust/JS Usage 读取逐次校验作用域授权,在固定结算快照下每页最多返回 100 条/48 KiB;
-  模型调用、工具执行和派发前拒绝共用筛选与活动分页。Host 重启使游标失效;聚合与 Usage 页面尚未接入。
+  模型调用、工具执行和派发前拒绝共用筛选与活动分页。Host 重启使游标失效。
+  汇总复用活动快照,保留缺失计数/报价覆盖率,并返回有界的完整提供商/模型/工具分组;待结算准入另列。
+  Usage 页面尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index e0c701ef13..718dc1f434 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -149,6 +149,8 @@ Activity entries are tagged `model` or `tool`. Optional `activity` filters selec
 
 Each page contains at most 100 attempts / 48 KiB. `{ kind: 'continue', cursor }` re-reads that page; `nextCursor` advances under the same filter and settlement fence. Host restart invalidates cursors; every read checks current authorization.
 
+`call.usage.summary(cursor)` uses the same range, Session and fence, ignoring activity-only filters. Token and cost subtotals include missing-call coverage; unpriced calls differ from priced calls with incomplete usage. Provider/model/tool breakdowns are complete or fail explicitly (128 groups each, 48 KiB overall). Pending counts use admission time within the range and are excluded from completed totals. Nonfinite or inexact integer aggregates fail rather than becoming zero.
+
 ## Pricing
 
 `ctx.pricing.query({ kind: 'start' })` reads the public rate catalog during activation. Continue with the returned revision and `nextOffset`; `revision_changed` requires a new scan. `call.pricing.update({ expectedRevision, mutation })` requires explicit profile `manage_pricing` consent. Edits share the native CAS and configuration notices, affect future admissions only, and finish after admission even if the caller stops waiting. A lost reply can yield `revision_conflict` on retry; query [...]
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index c5f0639f5b..a91c5c9832 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -153,6 +153,8 @@ Remote 回调可以抛出携带 `RemoteFailure.code` 的 `Error`。`outcome_unkn
 
 每页最多 100 条/48 KiB。`{ kind: 'continue', cursor }` 重读该页,`nextCursor` 在相同筛选条件与结算快照下翻页。Host 重启使游标失效;每次读取都重新检查当前授权。
 
+`call.usage.summary(cursor)` 复用相同的范围、Session 和快照,忽略活动列表筛选。Token 和费用小计保留缺失调用数;未报价与已报价但用量不完整分别统计。提供商/模型/工具分组必须完整,否则明确报错(各最多 128 组,整体 48 KiB)。待结算数按范围内的准入时间统计,不计入已完成总额。非有限值或无法精确表示的整数会报错,不变成零。
+
 ## 报价
 
 `ctx.pricing.query({ kind: 'start' })` 在激活期间即可读取公共报价。使用返回的 revision 与 `nextOffset` 继续分页,遇到 `revision_changed` 则重新读取。`call.pricing.update({ expectedRevision, mutation })` 需要 profile 范围的 `manage_pricing` 明确授权。修改复用原生 CAS 与配置通知,只影响后续准入;已接受的修改不会因调用者停止等待而中断。丢回复后重试可能返回 `revision_conflict`,应先查询再决定下一次修改。
diff --git a/packages/plugin-sdk/src/usage.ts b/packages/plugin-sdk/src/usage.ts
index 25f6c4915f..a97cd273a9 100644
--- a/packages/plugin-sdk/src/usage.ts
+++ b/packages/plugin-sdk/src/usage.ts
@@ -112,4 +112,43 @@ export interface Usage {
    * with a profile or Session target. No conversation bodies are exposed.
    */
   activity(input: UsageRead): Promise<UsagePage>;
+  /** Uses the activity cursor's scope, time range and fence, ignoring list filters.
+   * Known subtotals retain missing-call coverage; a zero estimate is not missing.
+   */
+  summary(cursor: string): Promise<UsageSummary>;
+}
+
+export interface UsageTokens {
+  known: number;
+  missing: number;
+}
+export interface UsageSummary {
+  /** Unsettled admissions within the time range at this fence, excluded from completed totals. */
+  pending: { models: number; tools: number };
+  /** Complete breakdowns, at most 128 groups each / 48 KiB overall; overflow fails explicitly. */
+  byProvider: readonly { providerId: string | null; totals: UsageSummary['models'] }[];
+  byModel: readonly { modelId: string; totals: UsageSummary['models'] }[];
+  byTool: readonly { name: string; totals: UsageSummary['tools'] }[];
+  models: {
+    calls: number;
+    success: number;
+    error: number;
+    aborted: number;
+    unknown: number;
+    input: UsageTokens;
+    output: UsageTokens;
+    cacheRead: UsageTokens;
+    cacheWrite: UsageTokens;
+    reasoning: UsageTokens;
+    cost: { knownUsd: number; unvalued: number; unpriced: number };
+  };
+  tools: {
+    calls: number;
+    success: number;
+    error: number;
+    unknown: number;
+    rejected: number;
+    /** Only known success/error settlements contribute to latency. */
+    meanLatencyMs: number | null;
+  };
 }


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


(maka) 06/09: feat(plugins): expose authorized physical model accounting

Posted by wy...@apache.org.
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

commit ec529622275999dbe030b3f312cbbac0fc59e06c
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 16:35:08 2026 +0800

    feat(plugins): expose authorized physical model accounting
    
    Generated-by: Codex
---
 .../src/main/plugin-authorization-dialog.ts        |   1 +
 crates/event-log/src/usage.rs                      |  69 +++-----
 crates/event-log/src/usage/auxiliary.rs            |  16 +-
 crates/event-log/tests/usage_paging.rs             | 138 ++++++++++++++++
 crates/js-runtime/src/plugin/sdk.js                |   1 +
 crates/plugins/src/authorization.rs                |   9 ++
 crates/plugins/src/host.rs                         |   1 +
 crates/plugins/src/lib.rs                          |   1 +
 crates/plugins/src/usage.rs                        |  74 +++++++++
 crates/runtime-host/src/execution/plugins.rs       |   1 +
 crates/runtime-host/src/execution/plugins/usage.rs | 175 +++++++++++++++++++++
 crates/runtime-host/src/plugins.rs                 |   1 +
 crates/runtime-host/src/plugins/effects.rs         |   4 +-
 crates/runtime-host/src/plugins/host.rs            |   1 +
 .../runtime-host/src/plugins/javascript/bridge.rs  |   6 +
 .../src/plugins/javascript/bridge/wire.rs          |   9 ++
 .../lib.rs => runtime-host/src/plugins/usage.rs}   |  48 ++----
 crates/runtime-host/src/server/interactions.rs     |   4 +
 .../src/server/plugin_authorization.rs             |   1 +
 .../tests/fixtures/background-plugin.mjs           |  37 ++++-
 crates/runtime-host/tests/fixtures/host-plugin.mjs |  23 +++
 .../tests/integration/plugin_authorization.rs      |   3 +
 .../integration/plugin_authorization/usage.rs      | 146 +++++++++++++++++
 crates/runtime/src/accounting.rs                   |  81 ++++++++++
 crates/runtime/src/lib.rs                          |   1 +
 docs/rust-parity.md                                |   4 +-
 docs/rust-parity.zh-CN.md                          |   4 +-
 docs/rust-runtime.md                               |   4 +-
 docs/rust-runtime.zh-CN.md                         |   3 +-
 packages/plugin-sdk/README.md                      |   6 +
 packages/plugin-sdk/README.zh-CN.md                |   6 +
 packages/plugin-sdk/src/authorization.ts           |   3 +-
 packages/plugin-sdk/src/host.ts                    |   2 +
 packages/plugin-sdk/src/usage.ts                   |  73 +++++++++
 .../src/protocol/plugin-authorization.ts           |  10 +-
 35 files changed, 858 insertions(+), 108 deletions(-)

diff --git a/apps/desktop/src/main/plugin-authorization-dialog.ts b/apps/desktop/src/main/plugin-authorization-dialog.ts
index 56525f7828..d65eb9bed5 100644
--- a/apps/desktop/src/main/plugin-authorization-dialog.ts
+++ b/apps/desktop/src/main/plugin-authorization-dialog.ts
@@ -36,6 +36,7 @@ export function pluginAuthorizationDialog(input: PluginAuthorizationInput, local
     notifications: ['Send notifications', '发送通知'],
     read_sessions: ['Read Session catalog', '读取会话目录'],
     read_history: ['Read conversation history, including archived Sessions', '读取对话历史(包括已归档会话)'],
+    read_usage: ['Read model usage and estimated costs', '读取模型用量和预估费用'],
   };
   const {request} = input.command;
   const target = request.target.kind === 'profile' ? 'profile' : request.target.kind === 'session'
diff --git a/crates/event-log/src/usage.rs b/crates/event-log/src/usage.rs
index ad30a3765c..477649764e 100644
--- a/crates/event-log/src/usage.rs
+++ b/crates/event-log/src/usage.rs
@@ -21,15 +21,11 @@
 //! Missing counters remain unknown; this layer makes no pricing assumptions.
 
 use crate::{EventLog, StoreError};
-use maka_runtime::{
-    context::ModelPurpose, event::Invocation, execution::ModelBinding, model::ModelUsage,
-};
-use serde::{Deserialize, Serialize};
+pub use maka_runtime::accounting::{AuxiliarySource, ModelAttempt, Origin, Outcome};
 use sqlx::{Connection, Row, sqlite::SqliteRow};
 
 mod auxiliary;
 pub(crate) mod valuation;
-pub use auxiliary::Source as AuxiliarySource;
 
 #[derive(Clone, Debug)]
 pub struct Query {
@@ -54,50 +50,6 @@ impl Query {
     }
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum Outcome {
-    Success,
-    Error,
-    Aborted,
-    /// The invocation ended without a recorded outcome for this admission.
-    Unknown,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct ModelAttempt {
-    pub request_id: String,
-    pub origin: Origin,
-    /// Frozen public identity; absent when the invocation had no binding.
-    pub binding: Option<ModelBinding>,
-    pub model_id: String,
-    pub started_at: f64,
-    pub completed_at: f64,
-    pub outcome: Outcome,
-    pub usage: ModelUsage,
-    pub quote: Option<maka_runtime::pricing::Quote>,
-    pub cost_usd: Option<f64>,
-}
-
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
-#[serde(tag = "kind", rename_all = "snake_case")]
-pub enum Origin {
-    Agent {
-        invocation: Invocation,
-        purpose: ModelPurpose,
-    },
-    Auxiliary {
-        source: AuxiliarySource,
-    },
-}
-
-impl ModelAttempt {
-    /// Wall-clock adjustments cannot produce negative reported latency.
-    pub fn latency_ms(&self) -> f64 {
-        (self.completed_at - self.started_at).max(0.0)
-    }
-}
-
 pub struct ModelPage {
     pub through: u64,
     pub attempts: Vec<ModelAttempt>,
@@ -145,7 +97,7 @@ impl EventLog {
                     .fetch_one(&mut *tx)
                     .await?;
                     let rows = sqlx::query(
-                        "SELECT event_id, origin, binding, model_id,
+                        "SELECT event_id, origin, session_id, binding, model_id,
                         started_at, completed_at, outcome, usage, quote_json, usd
                  FROM model_usage
                  WHERE completed_at >= ?1 AND completed_at <= ?2
@@ -161,7 +113,21 @@ impl EventLog {
                     .bind(offset as i64)
                     .fetch_all(&mut *tx)
                     .await?;
-                    let attempts = rows.iter().map(read).collect::<Result<Vec<_>, _>>()?;
+                    let mut attempts = Vec::with_capacity(rows.len());
+                    // Leave envelope/cursor headroom. Never truncate one accounting fact.
+                    let mut bytes = 0;
+                    for row in &rows {
+                        let attempt = read(row)?;
+                        let size = serde_json::to_vec(&attempt)?.len() + 1;
+                        if bytes + size > 44 * 1024 {
+                            if attempts.is_empty() {
+                                return Err(invalid("one Usage record exceeds page capacity"));
+                            }
+                            break;
+                        }
+                        bytes += size;
+                        attempts.push(attempt);
+                    }
                     let total = crate::sequence_number(total)?;
                     let end = offset + attempts.len() as u64;
                     tx.commit().await?;
@@ -182,6 +148,7 @@ fn read(row: &SqliteRow) -> Result<ModelAttempt, StoreError> {
     Ok(ModelAttempt {
         request_id: row.try_get("event_id")?,
         origin: serde_json::from_str(row.try_get("origin")?)?,
+        session_id: row.try_get("session_id")?,
         binding: row
             .try_get::<Option<&str>, _>("binding")?
             .map(serde_json::from_str)
diff --git a/crates/event-log/src/usage/auxiliary.rs b/crates/event-log/src/usage/auxiliary.rs
index 0d261a0096..3b3a92e5e5 100644
--- a/crates/event-log/src/usage/auxiliary.rs
+++ b/crates/event-log/src/usage/auxiliary.rs
@@ -17,26 +17,14 @@
  * under the License.
  */
 
-use super::{Outcome, invalid};
+use super::{AuxiliarySource as Source, Outcome, invalid};
 use crate::{EventLog, StoreError};
-use maka_runtime::{event::Invocation, execution::ModelBinding, model::ModelUsage};
+use maka_runtime::{execution::ModelBinding, model::ModelUsage};
 use serde::{Deserialize, Serialize};
 use sqlx::{Connection, SqliteConnection};
 use std::time::SystemTime;
 use uuid::Uuid;
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(tag = "kind", rename_all = "snake_case")]
-pub enum Source {
-    Agent {
-        invocation: Invocation,
-        operation_id: String,
-    },
-    HostEffect {
-        id: Uuid,
-    },
-}
-
 #[derive(Serialize, Deserialize)]
 pub(super) struct Admission {
     source: Source,
diff --git a/crates/event-log/tests/usage_paging.rs b/crates/event-log/tests/usage_paging.rs
new file mode 100644
index 0000000000..72e9c2b16d
--- /dev/null
+++ b/crates/event-log/tests/usage_paging.rs
@@ -0,0 +1,138 @@
+/*
+ * 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, usage::Query};
+use maka_runtime::{
+    context::ModelPurpose,
+    event::{
+        EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, LogScope, RuntimeEvent,
+    },
+    model::{ModelFinishReason, ModelStep, ModelUsage},
+};
+use std::{
+    collections::BTreeSet,
+    time::{Duration, UNIX_EPOCH},
+};
+
+#[tokio::test]
+async fn accounting_pages_bound_bytes_without_dropping_records_or_reading_response_bodies() {
+    let directory = tempfile::tempdir().unwrap();
+    let log = EventLog::open(&directory.path().join("usage.sqlite"))
+        .await
+        .unwrap();
+    log.append(&event(Fact::InvocationOpened {
+        configuration: None,
+        input: InvocationInput::Message {
+            content: "not Usage data".into(),
+            source_messages: vec![],
+            request_fingerprint: None,
+        },
+    }))
+    .await
+    .unwrap();
+    // Long public model IDs reach the byte bound before the item bound.
+    for i in 0..80 {
+        log.append_batch(&[
+            event(Fact::ModelRequested {
+                step_id: format!("step-{i}"),
+                model_id: "model".repeat(400),
+                purpose: ModelPurpose::Main,
+                source_scope: LogScope::Session { id: "usage".into() },
+                source_high_water: 0,
+                source_digest: "source".into(),
+                input_digest: "input".into(),
+                route_identity: "route".into(),
+                checkpoint_event_id: None,
+                context: None,
+                effective_source_digest: None,
+            }),
+            event(Fact::ModelCompleted {
+                step_id: format!("step-{i}"),
+                output: ModelStep {
+                    model: None,
+                    response_id: None,
+                    timestamp: None,
+                    parts: vec![maka_runtime::model::ModelPart::Text {
+                        text_kind: maka_runtime::model::TextKind::Text,
+                        text: "x".repeat(65536),
+                        provider_options: None,
+                    }],
+                    finish_reason: ModelFinishReason::Stop,
+                    usage: ModelUsage {
+                        input_tokens: Some(i),
+                        ..Default::default()
+                    },
+                    provider_options: None,
+                },
+            }),
+        ])
+        .await
+        .unwrap();
+    }
+    log.append(&event(Fact::InvocationEnded {
+        outcome: InvocationOutcome::Completed,
+    }))
+    .await
+    .unwrap();
+    let mut query = Query {
+        from: 10.25,
+        to: 10.25,
+        session_id: None,
+        through: None,
+    };
+    let mut offset = 0;
+    let mut seen = BTreeSet::new();
+    loop {
+        let page = log
+            .model_attempts(query.clone(), offset, 100)
+            .await
+            .unwrap();
+        assert_eq!(page.total, 80);
+        assert!(!page.attempts.is_empty() && page.attempts.len() < 80);
+        assert!(serde_json::to_vec(&page.attempts).unwrap().len() <= 44 * 1024);
+        for attempt in page.attempts {
+            assert!(seen.insert(attempt.request_id));
+            assert_eq!(attempt.session_id.as_deref(), Some("usage"));
+        }
+        query.through = Some(page.through);
+        match page.next_offset {
+            Some(next) => {
+                assert!(next > offset);
+                offset = next;
+            }
+            None => break,
+        }
+    }
+    assert_eq!(seen.len(), 80);
+    log.close().await.unwrap();
+}
+
+fn event(fact: Fact) -> EventWrite {
+    let mut event = RuntimeEvent::new(
+        Invocation {
+            session_id: "usage".into(),
+            turn_id: "turn".into(),
+            run_id: "run".into(),
+            invocation_id: "invocation".into(),
+        },
+        fact,
+    );
+    event.recorded_at = UNIX_EPOCH + Duration::from_micros(10250);
+    EventWrite::plain(event).unwrap()
+}
diff --git a/crates/js-runtime/src/plugin/sdk.js b/crates/js-runtime/src/plugin/sdk.js
index 1246a6f5fe..1016dd4723 100644
--- a/crates/js-runtime/src/plugin/sdk.js
+++ b/crates/js-runtime/src/plugin/sdk.js
@@ -297,6 +297,7 @@
   };
   const text = (value) => (typeof value === 'function' ? value : () => value);
   const resources = (authority) => ({
+    usage: Object.freeze({ models: (input) => host('usage.models', { authority, input }) }),
     sessions: Object.freeze({ list: (input = {}) => host('sessions.list', { authority, input }) }),
     history: Object.freeze({
       list: (input = {}) => host('history.list', { authority, input }),
diff --git a/crates/plugins/src/authorization.rs b/crates/plugins/src/authorization.rs
index 44d66ecab3..d952419982 100644
--- a/crates/plugins/src/authorization.rs
+++ b/crates/plugins/src/authorization.rs
@@ -43,6 +43,7 @@ pub enum Capability {
     Notifications,
     ReadSessions,
     ReadHistory,
+    ReadUsage,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -99,6 +100,7 @@ impl Request {
                         Capability::Notifications
                             | Capability::ReadSessions
                             | Capability::ReadHistory
+                            | Capability::ReadUsage
                     )
                 }) => {}
             Target::Profile => {
@@ -138,6 +140,13 @@ impl Request {
                 self.validate_mode(*sandbox_mode)?;
             }
         }
+        if self.capabilities.contains(&Capability::ReadUsage)
+            && !matches!(self.target, Target::Profile | Target::Session { .. })
+        {
+            return Err(crate::Error::Invalid(
+                "Usage authorization requires a profile or Session target".into(),
+            ));
+        }
         Ok(())
     }
     /// Explicit HTTP consent is independent of the file/process sandbox. Client
diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs
index 7ac9cc24ce..1f1c291982 100644
--- a/crates/plugins/src/host.rs
+++ b/crates/plugins/src/host.rs
@@ -32,6 +32,7 @@ pub struct Services {
     pub executions: Arc<dyn Access>,
     pub sessions: Arc<dyn crate::session::catalog::Queries>,
     pub history: Arc<dyn crate::session::history::History>,
+    pub usage: Arc<dyn crate::usage::Usage>,
     pub authorizations: Arc<dyn crate::authorization::Access>,
     pub permissions: Arc<dyn crate::permissions::Access>,
     pub files: Arc<dyn crate::filesystem::Files>,
diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs
index fb6794ed47..36c6d2ec70 100644
--- a/crates/plugins/src/lib.rs
+++ b/crates/plugins/src/lib.rs
@@ -51,6 +51,7 @@ pub mod services;
 pub mod session;
 pub mod storage;
 pub mod terminal;
+pub mod usage;
 
 #[derive(Debug, thiserror::Error, PartialEq, Eq)]
 pub enum Error {
diff --git a/crates/plugins/src/usage.rs b/crates/plugins/src/usage.rs
new file mode 100644
index 0000000000..51e63961d5
--- /dev/null
+++ b/crates/plugins/src/usage.rs
@@ -0,0 +1,74 @@
+/*
+ * 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::{call::Scope, execution::CommandError};
+use futures_util::future::BoxFuture;
+use serde::{Deserialize, Serialize};
+
+pub use maka_runtime::accounting::{AuxiliarySource, ModelAttempt, Origin, Outcome};
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Filter {
+    /// Inclusive finite Unix milliseconds. Fractional timestamps are preserved.
+    pub from: f64,
+    pub to: f64,
+    pub session_id: Option<String>,
+}
+impl Filter {
+    pub fn validate(&self) -> Result<(), CommandError> {
+        if !self.from.is_finite() || !self.to.is_finite() || self.from < 0.0 || self.to < self.from
+        {
+            return Err(CommandError::Invalid("invalid Usage time range".into()));
+        }
+        if let Some(id) = &self.session_id {
+            crate::name(id).map_err(|error| CommandError::Invalid(error.to_string()))?;
+        }
+        Ok(())
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Read {
+    Start {
+        filter: Filter,
+    },
+    /// Opaque continuation, not authority. Invalidated by Host restart.
+    Continue {
+        cursor: String,
+    },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Page {
+    /// Re-read this exact page under its original filter and settlement fence.
+    pub cursor: String,
+    pub next_cursor: Option<String>,
+    pub attempts: Vec<ModelAttempt>,
+    pub total: u64,
+}
+
+/// Metadata only: no prompt, response body, credentials or raw event access.
+/// Agent calls see their Session; independent calls require read_usage consent.
+pub trait Usage: Send + Sync {
+    /// At most 100 rows / 48 KiB. Every continuation rechecks current authority.
+    fn models(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>>;
+}
diff --git a/crates/runtime-host/src/execution/plugins.rs b/crates/runtime-host/src/execution/plugins.rs
index 6df4106a4d..aaf98bd294 100644
--- a/crates/runtime-host/src/execution/plugins.rs
+++ b/crates/runtime-host/src/execution/plugins.rs
@@ -38,6 +38,7 @@ use root::RootGrant;
 mod catalog;
 mod history;
 mod scopes;
+mod usage;
 pub(crate) use scopes::ResourceTarget;
 mod workspace;
 
diff --git a/crates/runtime-host/src/execution/plugins/usage.rs b/crates/runtime-host/src/execution/plugins/usage.rs
new file mode 100644
index 0000000000..41d14529bc
--- /dev/null
+++ b/crates/runtime-host/src/execution/plugins/usage.rs
@@ -0,0 +1,175 @@
+/*
+ * 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 super::{Error, Executions, storage};
+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
+use maka_plugins::{
+    authorization::{Boundary, Capability},
+    call::Scope,
+    usage::{Filter, Page, Read},
+};
+use serde::{Deserialize, Serialize};
+
+const MAX_NUMBER: u64 = (1 << 53) - 1;
+
+/// Carries the entire frozen query, so continuations cannot accidentally reuse
+/// an offset with a different filter. It never substitutes for authorization.
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Cursor {
+    generation: String,
+    filter: Filter,
+    through: u64,
+    offset: u64,
+}
+impl Cursor {
+    fn decode(value: &str, generation: &str) -> Result<Self, Error> {
+        if value.len() > 1024 {
+            return Err(Error::Invalid("oversized Usage cursor".into()));
+        }
+        let cursor: Self = serde_json::from_slice(
+            &URL_SAFE_NO_PAD
+                .decode(value)
+                .map_err(|_| invalid_cursor())?,
+        )
+        .map_err(|_| invalid_cursor())?;
+        cursor.filter.validate()?;
+        if cursor.generation != generation {
+            return Err(Error::Conflict);
+        }
+        if cursor.through > MAX_NUMBER || cursor.offset > MAX_NUMBER {
+            return Err(invalid_cursor());
+        }
+        Ok(cursor)
+    }
+    fn encode(&self) -> Result<String, Error> {
+        let bytes = serde_json::to_vec(self).map_err(|_| invalid_cursor())?;
+        let encoded = URL_SAFE_NO_PAD.encode(bytes);
+        if encoded.len() > 1024 {
+            return Err(invalid_cursor());
+        }
+        Ok(encoded)
+    }
+}
+fn invalid_cursor() -> Error {
+    Error::Invalid("invalid Usage cursor".into())
+}
+
+impl Executions {
+    async fn usage_boundary(&self, call: &Scope) -> Result<Boundary, Error> {
+        if call.identity.agent().is_some() {
+            self.plugin_execution_boundary(call).await
+        } else {
+            self.plugin_resource_boundary(call, Capability::ReadUsage)
+                .await
+        }
+    }
+
+    pub(crate) async fn plugin_usage_models(
+        &self,
+        call: Scope,
+        input: Read,
+    ) -> Result<Page, Error> {
+        let (mut filter, through, offset) = match input {
+            Read::Start { filter } => (filter, None, 0),
+            Read::Continue { cursor } => {
+                let cursor = Cursor::decode(&cursor, self.interactions.epoch())?;
+                (cursor.filter, Some(cursor.through), cursor.offset)
+            }
+        };
+        filter.validate()?;
+        let requested = filter.clone();
+        restrict(self.usage_boundary(&call).await?, &mut filter)?;
+        if through.is_some() && requested != filter {
+            return Err(Error::Denied);
+        }
+        let result = self
+            .log
+            .model_attempts(
+                maka_event_log::usage::Query {
+                    from: filter.from,
+                    to: filter.to,
+                    session_id: filter.session_id.clone(),
+                    through,
+                },
+                offset,
+                100,
+            )
+            .await
+            .map_err(|error| match error {
+                maka_event_log::StoreError::InvalidTransition(message) => Error::Invalid(message),
+                other => storage(other),
+            })?;
+        // Reading can yield to consent revocation or a Session boundary change.
+        let mut checked = filter.clone();
+        restrict(self.usage_boundary(&call).await?, &mut checked)?;
+        if checked != filter {
+            return Err(Error::Revoked);
+        }
+        if result.through > MAX_NUMBER || result.total > MAX_NUMBER || offset > result.total {
+            return Err(invalid_cursor());
+        }
+        let mut cursor = Cursor {
+            generation: self.interactions.epoch().to_owned(),
+            filter,
+            through: result.through,
+            offset,
+        };
+        let current = cursor.encode()?;
+        let next_cursor = result
+            .next_offset
+            .map(|offset| {
+                cursor.offset = offset;
+                cursor.encode()
+            })
+            .transpose()?;
+        let page = Page {
+            cursor: current,
+            next_cursor,
+            attempts: result.attempts,
+            total: result.total,
+        };
+        if serde_json::to_vec(&page)
+            .map_err(|_| invalid_cursor())?
+            .len()
+            > 48 * 1024
+        {
+            return Err(Error::Invalid("Usage page exceeds capacity".into()));
+        }
+        Ok(page)
+    }
+}
+
+fn restrict(boundary: Boundary, filter: &mut Filter) -> Result<(), Error> {
+    match boundary {
+        Boundary::Profile => Ok(()),
+        Boundary::Session { boundary, .. } => {
+            if filter
+                .session_id
+                .as_ref()
+                .is_some_and(|id| id != &boundary.session_id)
+            {
+                return Err(Error::Denied);
+            }
+            filter.session_id = Some(boundary.session_id);
+            Ok(())
+        }
+        Boundary::Workspace { .. } | Boundary::Directory { .. } => Err(Error::Denied),
+    }
+}
diff --git a/crates/runtime-host/src/plugins.rs b/crates/runtime-host/src/plugins.rs
index 08ff8cc670..1b74b980d1 100644
--- a/crates/runtime-host/src/plugins.rs
+++ b/crates/runtime-host/src/plugins.rs
@@ -36,6 +36,7 @@ pub(crate) mod skills;
 pub(crate) mod storage;
 mod terminal;
 pub(crate) mod todo;
+mod usage;
 pub(crate) mod web;
 pub mod wire;
 pub(crate) mod workhub;
diff --git a/crates/runtime-host/src/plugins/effects.rs b/crates/runtime-host/src/plugins/effects.rs
index c70c457f7b..104c4f75b1 100644
--- a/crates/runtime-host/src/plugins/effects.rs
+++ b/crates/runtime-host/src/plugins/effects.rs
@@ -28,8 +28,8 @@ use std::sync::{Arc, Weak};
 use tokio_util::sync::CancellationToken;
 
 pub(super) struct Effects {
-    host: Weak<Executions>,
-    owner: Context,
+    pub(super) host: Weak<Executions>,
+    pub(super) owner: Context,
 }
 impl Effects {
     pub fn new(host: Weak<Executions>, owner: Context) -> Self {
diff --git a/crates/runtime-host/src/plugins/host.rs b/crates/runtime-host/src/plugins/host.rs
index 8b2b10c31a..ab3329d094 100644
--- a/crates/runtime-host/src/plugins/host.rs
+++ b/crates/runtime-host/src/plugins/host.rs
@@ -82,6 +82,7 @@ impl Provider for Issuer {
                 clients: effects.clone(),
                 sessions: effects.clone(),
                 history: effects.clone(),
+                usage: effects.clone(),
                 http: Arc::new(super::http::Http::new(
                     self.executions.clone(),
                     owner.clone(),
diff --git a/crates/runtime-host/src/plugins/javascript/bridge.rs b/crates/runtime-host/src/plugins/javascript/bridge.rs
index 83eff2fd50..e8ab6ed321 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge.rs
@@ -57,6 +57,7 @@ struct State {
     commands: Arc<dyn Access>,
     sessions: Arc<dyn maka_plugins::session::catalog::Queries>,
     history: Arc<dyn maka_plugins::session::history::History>,
+    usage: Arc<dyn maka_plugins::usage::Usage>,
     execution_handles: Mutex<BTreeMap<String, Arc<dyn maka_plugins::execution::Commands>>>,
     authorizations: Arc<dyn maka_plugins::authorization::Access>,
     authorized_calls:
@@ -99,6 +100,7 @@ impl HostBridge {
             commands: host.executions,
             sessions: host.sessions,
             history: host.history,
+            usage: host.usage,
             execution_handles: Mutex::default(),
             authorizations: host.authorizations,
             authorized_calls: Mutex::default(),
@@ -301,6 +303,10 @@ impl State {
                 encode(self.history.copy_session(call, target, input.input).await?)
             }
             Request::SearchModels(input) => encode(self.models.search(input).await?),
+            Request::UsageModels(input) => {
+                let call = self.calls.get(&input.authority)?;
+                encode(self.usage.models(call, input.input).await?)
+            }
             Request::SearchExecutors(input) => encode(self.executors.search(input).await?),
             Request::Revision(input) => {
                 let _lease = self.context.lifecycle.resource_call()?;
diff --git a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
index 10563a4ba4..9c144e7d87 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
@@ -77,6 +77,8 @@ pub(super) enum Request {
     HistoryList(SessionList),
     #[serde(rename = "history.read")]
     HistoryRead(HistoryRead),
+    #[serde(rename = "usage.models")]
+    UsageModels(Authorized<maka_plugins::usage::Read>),
     #[serde(rename = "history.sources")]
     HistorySources(HistorySources),
     #[serde(rename = "history.copyMaterial")]
@@ -560,6 +562,13 @@ pub(super) struct HistoryRead {
     pub input: maka_plugins::session::history::Read,
 }
 
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct Authorized<T> {
+    pub authority: String,
+    pub input: T,
+}
+
 #[derive(Deserialize)]
 #[serde(deny_unknown_fields)]
 pub(super) struct HistorySources {
diff --git a/crates/runtime/src/lib.rs b/crates/runtime-host/src/plugins/usage.rs
similarity index 57%
copy from crates/runtime/src/lib.rs
copy to crates/runtime-host/src/plugins/usage.rs
index 22fea9930a..d032804e18 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime-host/src/plugins/usage.rs
@@ -17,34 +17,20 @@
  * under the License.
  */
 
-//! Runtime facts and execution contracts; no V8 or SQLite dependency.
-pub mod access;
-pub mod archive;
-pub mod artifact;
-pub mod attachment;
-pub mod capability;
-pub mod composition;
-pub mod configuration;
-pub mod context;
-pub mod continuation;
-pub mod event;
-mod event_write;
-pub mod execution;
-pub mod executor;
-pub mod handoff;
-pub mod input;
-pub mod interaction;
-pub mod message;
-pub mod model;
-pub mod oauth;
-pub mod pricing;
-pub mod provider;
-pub mod read;
-pub mod scope;
-pub mod session;
-pub mod shell_result;
-pub mod shell_run;
-pub mod terminal;
-pub mod tool_call;
-pub mod tool_output;
-pub mod tools;
+use super::effects::Effects;
+use futures_util::future::BoxFuture;
+use maka_plugins::{
+    call::Scope,
+    execution::CommandError,
+    usage::{Page, Read, Usage},
+};
+
+impl Usage for Effects {
+    fn models(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>> {
+        Box::pin(async move {
+            let _lease = self.owner.admit().map_err(|_| CommandError::Revoked)?;
+            let host = self.host.upgrade().ok_or(CommandError::Draining)?;
+            host.plugin_usage_models(call, input).await
+        })
+    }
+}
diff --git a/crates/runtime-host/src/server/interactions.rs b/crates/runtime-host/src/server/interactions.rs
index ef2ba3717f..ec9f06dfcc 100644
--- a/crates/runtime-host/src/server/interactions.rs
+++ b/crates/runtime-host/src/server/interactions.rs
@@ -90,6 +90,10 @@ impl ClientInteractions for Interactions {
     }
 }
 impl Interactions {
+    pub(crate) fn epoch(&self) -> &str {
+        &self.epoch
+    }
+
     pub(super) fn new(
         log: Arc<EventLog>,
         shutdown: CancellationToken,
diff --git a/crates/runtime-host/src/server/plugin_authorization.rs b/crates/runtime-host/src/server/plugin_authorization.rs
index 21682afc9b..11eea3c8a6 100644
--- a/crates/runtime-host/src/server/plugin_authorization.rs
+++ b/crates/runtime-host/src/server/plugin_authorization.rs
@@ -292,6 +292,7 @@ fn allow(authority: &Authority, request: &Request) -> Result<(), OperationError>
             | Capability::ClientCapabilities => &[Operation::TurnStart],
             Capability::Notifications => &[],
             Capability::ReadSessions => &[Operation::SessionCatalogQuery],
+            Capability::ReadUsage => &[Operation::UsageQuery],
             Capability::ReadHistory => &[
                 Operation::SessionCatalogQuery,
                 Operation::SessionTranscriptPage,
diff --git a/crates/runtime-host/tests/fixtures/background-plugin.mjs b/crates/runtime-host/tests/fixtures/background-plugin.mjs
index 4ad355ef7a..cebef37a2e 100644
--- a/crates/runtime-host/tests/fixtures/background-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/background-plugin.mjs
@@ -19,6 +19,26 @@
 
 /** @param {import('../../../../packages/plugin-sdk/src/host.js').HostContext} ctx */
 export default async function (ctx) {
+  await ctx.remote.method('usage', async (input) => {
+    if (
+      !input ||
+      typeof input !== 'object' ||
+      Array.isArray(input) ||
+      !('grant' in input) ||
+      typeof input.grant !== 'string' ||
+      !('read' in input)
+    )
+      throw new Error('invalid Usage input');
+    const read = /** @type {import('../../../../packages/plugin-sdk/src/usage.js').UsageRead} */ (
+      input.read
+    );
+    try {
+      const page = await ctx.withAuthorization(input.grant, (call) => call.usage.models(read));
+      return JSON.parse(JSON.stringify({ page }));
+    } catch (error) {
+      return { error: error.code };
+    }
+  });
   await ctx.remote.method('network', async (input) => {
     const intent = parseIntent(input);
     return ctx
@@ -242,7 +262,19 @@ export default async function (ctx) {
         if (stale.kind !== 'revision_conflict')
           throw new Error('removal ignored its revision fence');
       }
-      const removal = deletion.data.value;
+      if (deletion.data.kind !== 'present') throw new Error('removal intent was deleted');
+      const value = deletion.data.value;
+      if (
+        !value ||
+        typeof value !== 'object' ||
+        Array.isArray(value) ||
+        !('sessionId' in value) ||
+        typeof value.sessionId !== 'string' ||
+        !('expectedRevision' in value) ||
+        typeof value.expectedRevision !== 'number'
+      )
+        throw new Error('invalid removal intent');
+      const removal = { sessionId: value.sessionId, expectedRevision: value.expectedRevision };
       const previousRemoval = await commands.removalReceipt(removal.sessionId);
       if (!previousRemoval) {
         const removed = await commands.removeSession(removal);
@@ -250,7 +282,8 @@ export default async function (ctx) {
       }
       const durableRemoval = await commands.removalReceipt(removal.sessionId);
       if (
-        durableRemoval?.sessionId !== removal.sessionId ||
+        !durableRemoval ||
+        durableRemoval.sessionId !== removal.sessionId ||
         durableRemoval.archivedSubtaskCount !== 0
       )
         throw new Error('removal receipt did not survive retirement or restart');
diff --git a/crates/runtime-host/tests/fixtures/host-plugin.mjs b/crates/runtime-host/tests/fixtures/host-plugin.mjs
index b72e163b7a..8523228522 100644
--- a/crates/runtime-host/tests/fixtures/host-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/host-plugin.mjs
@@ -434,6 +434,29 @@ export default async function (ctx) {
             generated.usage.output_tokens !== 5
           )
             throw new Error('nested model result or usage was lost');
+          const usage = await call.usage.models({
+            kind: 'start',
+            filter: { from: 0, to: 1e15 },
+          });
+          if (
+            !usage.attempts.some(
+              (row) =>
+                row.origin.kind === 'auxiliary' &&
+                row.usage.input_tokens === 3 &&
+                row.usage.output_tokens === 5,
+            ) ||
+            usage.attempts.some((row) => row.sessionId !== call.invocation.session_id)
+          )
+            throw new Error('Agent accounting lost usage or escaped its Session');
+          try {
+            await call.usage.models({
+              kind: 'start',
+              filter: { from: 0, to: 1e15, sessionId: 'foreign-session' },
+            });
+            throw new Error('Agent Usage read widened authority');
+          } catch (error) {
+            if (error.code !== 'revoked') throw error;
+          }
           for (let generation = 0; generation < 300; generation++) {
             const dynamic = await ctx.prompt.variable('temporary', () => String(generation));
             await dynamic.close();
diff --git a/crates/runtime-host/tests/integration/plugin_authorization.rs b/crates/runtime-host/tests/integration/plugin_authorization.rs
index 6234fe607a..2154478a84 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization.rs
@@ -23,6 +23,7 @@ use maka_runtime_host::server::{Host, local::LocalListener};
 use serde_json::{Value, json};
 use std::time::Duration;
 use tokio_util::sync::CancellationToken;
+mod usage;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
 async fn external_background_consent_recovers_and_revokes_without_losing_accepted_work() {
@@ -107,6 +108,7 @@ async fn scenario() {
     let mut root_result = Value::Null;
     let mut network_grant = Value::Null;
     let mut material_copy = Value::Null;
+    let mut usage = usage::Probe::default();
     for reopened in [false, true] {
         let host = Host::open(fixture.owner()).await.unwrap();
         #[cfg(unix)]
@@ -510,6 +512,7 @@ async fn scenario() {
         } else {
             accepted = result["receipt"].clone();
         }
+        usage.check(&mut peer, &client, &document, reopened).await;
         peer.close().await;
         stop.cancel();
         server.await.unwrap().unwrap();
diff --git a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
new file mode 100644
index 0000000000..c4abce6e1e
--- /dev/null
+++ b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
@@ -0,0 +1,146 @@
+/*
+ * 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 super::{Peer, Value, json, remote, success};
+
+#[derive(Default)]
+pub(super) struct Probe {
+    grant: Value,
+    cursor: Value,
+}
+impl Probe {
+    pub(super) async fn check(
+        &mut self,
+        peer: &mut Peer,
+        client: &Value,
+        document: &Value,
+        reopened: bool,
+    ) {
+        if !reopened {
+            self.grant = approve(peer, client, "profile", None).await;
+        } else {
+            let stale = read(
+                peer,
+                client,
+                document,
+                &self.grant,
+                json!({"kind":"continue", "cursor":self.cursor}),
+            )
+            .await;
+            assert_eq!(
+                stale["error"], "conflict",
+                "a restarted Host invalidates page cursors"
+            );
+        }
+        let start = json!({"kind":"start", "filter":{"from":0.0,"to":1e15}});
+        let result = read(peer, client, document, &self.grant, start.clone()).await;
+        let page = &result["page"];
+        assert!(page["total"].as_u64().unwrap() > 0, "{result}");
+        for row in page["attempts"].as_array().unwrap() {
+            let attempt: maka_plugins::usage::ModelAttempt =
+                serde_json::from_value(row.clone()).unwrap();
+            assert_eq!(attempt.cost_usd, None);
+            assert_eq!(attempt.quote.unwrap().provider_id, "openai-compatible");
+            assert!(row.get("prompt").is_none() && row.get("text").is_none());
+        }
+        let replay = read(
+            peer,
+            client,
+            document,
+            &self.grant,
+            json!({"kind":"continue","cursor":page["cursor"]}),
+        )
+        .await;
+        assert_eq!(replay, result, "re-reading preserves the same snapshot");
+        self.cursor = page["cursor"].clone();
+
+        let session = approve(peer, client, "session", Some("background-session")).await;
+        let scoped = read(peer, client, document, &session, start.clone()).await;
+        assert!(
+            scoped["page"]["attempts"]
+                .as_array()
+                .unwrap()
+                .iter()
+                .all(|row| row["sessionId"] == "background-session")
+        );
+        let denied = read(
+            peer,
+            client,
+            document,
+            &session,
+            json!({"kind":"start","filter":{"from":0,"to":1e15,"sessionId":"foreign"}}),
+        )
+        .await;
+        assert_eq!(denied["error"], "revoked");
+        // A profile cursor is not a transferable profile grant.
+        let denied = read(
+            peer,
+            client,
+            document,
+            &session,
+            json!({"kind":"continue","cursor":self.cursor}),
+        )
+        .await;
+        assert_eq!(denied["error"], "revoked");
+
+        if reopened {
+            success(peer.rpc("plugin.authorization", json!({
+                "client":client,"scope":"profile","command":{"kind":"revoke","id":self.grant["id"]}
+            })).await);
+            let denied = read(peer, client, document, &self.grant, start).await;
+            assert_eq!(denied["error"], "revoked");
+        }
+    }
+}
+
+async fn approve(peer: &mut Peer, client: &Value, kind: &str, session: Option<&str>) -> Value {
+    let target = session.map_or_else(
+        || json!({"kind":kind}),
+        |id| json!({"kind":kind,"sessionId":id}),
+    );
+    success(
+        peer.rpc(
+            "plugin.authorization",
+            json!({
+                "client":client,"scope":"profile","command":{"kind":"approve","request":{
+                    "operationId":uuid::Uuid::new_v4(),"title":"Read model usage",
+                    "target":target,"capabilities":["read_usage"]
+                }}
+            }),
+        )
+        .await,
+    )["grant"]
+        .clone()
+}
+async fn read(
+    peer: &mut Peer,
+    client: &Value,
+    document: &Value,
+    grant: &Value,
+    read: Value,
+) -> Value {
+    remote(
+        peer,
+        client,
+        document,
+        "usage",
+        json!({"grant":grant["id"],"read":read}),
+    )
+    .await
+}
diff --git a/crates/runtime/src/accounting.rs b/crates/runtime/src/accounting.rs
new file mode 100644
index 0000000000..4c75ac0988
--- /dev/null
+++ b/crates/runtime/src/accounting.rs
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ */
+
+//! Physical model attempts; independent of conversation membership and current prices.
+
+use crate::{context::ModelPurpose, event::Invocation, execution::ModelBinding, model::ModelUsage};
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Outcome {
+    Success,
+    Error,
+    Aborted,
+    /// The admission ended without a recorded provider outcome.
+    Unknown,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AuxiliarySource {
+    Agent {
+        invocation: Invocation,
+        operation_id: String,
+    },
+    HostEffect {
+        id: Uuid,
+    },
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Origin {
+    Agent {
+        invocation: Invocation,
+        purpose: ModelPurpose,
+    },
+    Auxiliary {
+        source: AuxiliarySource,
+    },
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ModelAttempt {
+    pub request_id: String,
+    pub origin: Origin,
+    pub session_id: Option<String>,
+    pub binding: Option<ModelBinding>,
+    pub model_id: String,
+    pub started_at: f64,
+    pub completed_at: f64,
+    pub outcome: Outcome,
+    pub usage: ModelUsage,
+    pub quote: Option<crate::pricing::Quote>,
+    pub cost_usd: Option<f64>,
+}
+
+impl ModelAttempt {
+    /// Wall-clock adjustments cannot produce negative reported latency.
+    pub fn latency_ms(&self) -> f64 {
+        (self.completed_at - self.started_at).max(0.0)
+    }
+}
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
index 22fea9930a..4506bad406 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime/src/lib.rs
@@ -19,6 +19,7 @@
 
 //! Runtime facts and execution contracts; no V8 or SQLite dependency.
 pub mod access;
+pub mod accounting;
 pub mod archive;
 pub mod artifact;
 pub mod attachment;
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index f5ca6f392e..d5a3458458 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen per-call valuation, bounded source reads, and native pricing query/CAS edits are implemented. Public plugin accounting/pricing capabilities and revision-consistent Usage screens/activity pages remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped model-attempt paging, and native pricing query/CAS edits are implemented. Public pricing capabilities, aggregates and Usage screens/activity pages remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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. |
@@ -112,7 +112,7 @@ Original TS plugins are not source-compatible with the new SDK.
 | Skills: tool publication | Skill/SkillSearch are ordinary Contributions, without a package-name privilege. Per-step bindings capture handlers and supporting context together, after tool ceilings; physical retries retain the same snapshot. |
 | WorkHub: precise execution commands | Typed commands carry stable operation IDs, exact targets and expected revisions. Correction freezes plugin intent before exact Host control/submission and atomically commits its business receipt; accepted work settles independently of plugin availability. Queue editing preserves the original submission proof. Plugins receive neither SQL transaction callbacks nor unrestricted execution handles. |
 | WorkHub / Graph / Plan: selectable behavior | Open `BehaviorId` selects a typed Contribution; Graph/Swarm register independently. Host acceptance covers a non-builtin business. Preserve Session defaults and durable per-Turn choices; a requested unavailable behavior fails explicitly. Behavior preparation and input preparation are separate contracts, not one hook bus. |
-| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Usage queries still need consumer-driven contracts. Domain catalog/mutation APIs can be typed plugin Services rather than new kernel methods. |
+| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Public Usage reads expose scoped physical model attempts without conversation bodies. Aggregates and reporting remain. Domain catalog/mutation APIs can be typed plugin Services rather than new kernel methods. |
 | Skills / WorkHub / default behavior: business UI and prompt context | Published Clients own the actual Skills picker/management and WorkHub surface through Slots and Remote. Native adapters validate the originating Host and document; connection replacement revokes old Remote leases without replay. Prompt Contributions own business instructions. Inactive features remain visibly unavailable without blocking ordinary chat. |
 | Other TS extension services | New SDK lacks equivalent public registrations/services for LSP routing, commands, Skills/Goals queries, shell environment contributions, Settings definitions, authorization flows and LLM adapter registration; questions/forms and source-input/attachment-copy contracts are public; permission approvals remain Host-owned. Implement domain registries as plugin services where possible, retaining Host authority for sensitive actions. `llm.generate` is not adapter [...]
 
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index d7f3c734a2..021187f510 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结的逐调用估价、有界事实读取及原生报价查询/CAS 修改。公共插件计量/报价能力、一致版本的 Usage 视图与活动分页仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型调用分页及原生报价查询/CAS 修改。公共报价能力、聚合统计、Usage 视图与活动分页仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
@@ -101,7 +101,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Skills:工具发布 | Skill/SkillSearch 是普通 Contribution,不享有包名特权。每步绑定在工具上限内共同捕获 handler 和支持上下文,物理重试保持原快照。 |
 | WorkHub:精确执行命令 | 类型化命令携带稳定操作 ID、精确目标及预期 revision。纠正先冻结插件意图,再精确控制/提交 Host 工作,最后原子记录业务回执;已接受工作不依赖插件可用性继续结算。队列编辑保留原提交凭证。插件不获得 SQL 事务回调或无限制执行句柄。 |
 | WorkHub/Graph/Plan:可选择的 behavior | 已用开放的 `BehaviorId` 选择类型化 Contribution,Graph/Swarm 独立注册;非内置业务已通过 Host 验收。保留 Session 默认值和持久单 Turn 选择;请求的 behavior 不可用时明确失败。behavior 准备与输入准备是独立契约,不合并为 hook 总线。 |
-| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。用量查询仍需按消费者补契约。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
+| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。公共 Usage 读取提供作用域内的模型物理调用,不暴露对话正文;聚合与报表仍待完成。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
 | Skills/WorkHub/默认行为:业务 UI 与 Prompt 上下文 | 发布的 Client 通过 Slot 和 Remote 拥有真实 Skills 选择器/管理页及 WorkHub 界面。原生适配验证原 Host 与 document;连接换代撤销旧 Remote 租约,不重放调用。Prompt Contribution 拥有业务指令。功能停用明确显示不可用,不阻塞普通聊天。 |
 | 其余 TS 扩展服务 | 新 SDK 尚缺等价的公开 LSP 路由、Commands、Skills/Goals 查询、shell 环境变量 Contribution、Settings 定义、授权流程及 LLM adapter 注册;问题/表单、源输入与附件复制已有公共契约;权限审批仍归 Host。能由插件服务实现的领域注册放在插件侧,敏感行为权威仍归 Host。`llm.generate` 不等于 adapter 注册。 |
 
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 10f2f76d72..a351c38873 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -218,7 +218,9 @@ user/project content paths stay separate from private journals.
   retains accounting facts. Pricing queries and CAS edits use a bounded, revision-pinned
   catalog; custom rates survive restart and bundled-rate updates invalidate old cursors.
   Each admission captures its public provider identity and rate decision; estimates commit
-  with reported usage and survive rejection, edits and recovery. Usage screens are not yet connected.
+  with reported usage and survive rejection, edits and recovery. Public Rust/JS Usage reads
+  recheck scoped authorization and return at most 100 rows / 48 KiB under a fixed settlement
+  fence; continuation cursors expire on Host restart. Usage screens are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 529af2b9c2..d41fb4065e 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -170,7 +170,8 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
   缺失计数与未确认结果保持未知。历史复制不重复计量,Session 删除保留计量事实。
   报价支持有界、固定版本的查询与 CAS 修改;自定义报价跨重启保留,内置报价更新使旧分页失效。
   每次准入冻结公共提供商身份和报价,用量与估价一起提交;回复被拒绝、改价或恢复都不改写历史金额。
-  Usage 页面尚未接入。
+  公共 Rust/JS Usage 读取逐次校验作用域授权,在固定结算快照下每页最多返回 100 条/48 KiB;
+  Host 重启使继续读取的游标失效。Usage 页面尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index e74b093a86..a151067488 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -141,6 +141,12 @@ Native endpoints accepting caller-supplied Host paths declare `Endpoint::requiri
 
 `restoreRoot(operationId)` recovers a root created by this package/scope under current workspace and source ceilings, independently of its original model. Creation provenance does not make an ordinary root exclusively managed. `restoreChild` requires the original child creation request. Neither creates anything; absence does not exclude a concurrent creation. `configure` advances the Session revision on every committed choice, including identical values, fencing older configuration CASes [...]
 
+## Usage
+
+`call.usage.models({ kind: 'start', filter: { from, to, sessionId? } })` reads settled physical model attempts, including failed retries and auxiliary calls. Agent calls see their Session; independent calls need `read_usage` authorization for a profile or Session. Responses contain no conversation bodies. Missing usage, price and outcome remain distinct unknowns.
+
+Each page contains at most 100 attempts / 48 KiB. `{ kind: 'continue', cursor }` re-reads that page; `nextCursor` advances under the same filter and settlement fence. Host restart invalidates cursors; every read checks current authorization.
+
 ## Model adapters
 
 `ctx.modelAdapters.register(name, open)` publishes a protocol adapter. `open('request' | 'conversation')` returns `stream(request, context)` and optional `confirm(history)`. Rust uses `maka_plugins::model::ProviderAdapter` and the same typed events, HTTP and WebSocket contracts. Model overrides select an adapter by `adapter`; defaults are `responses`, `chat-completions` and `anthropic-messages`.
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index 7466db1e20..88aafbc7b2 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -145,6 +145,12 @@ Remote 回调可以抛出携带 `RemoteFailure.code` 的 `Error`。`outcome_unkn
 
 `restoreRoot(operationId)` 按当前工作区和来源上限恢复本包/作用域创建的根会话,不依赖原模型;创建记录不会使普通根会话变成独占托管会话。`restoreChild` 要求原始子会话创建请求。两者均不创建资源;不存在的观察不能排除并发创建。`configure` 每次成功选择都会推进 Session revision,包括相同值,以阻止较早的配置 CAS 覆盖它,不修改事件历史。
 
+## 用量
+
+`call.usage.models({ kind: 'start', filter: { from, to, sessionId? } })` 读取已结算的模型物理调用,包含失败重试和辅助调用。Agent 只读当前 Session;独立调用需要 profile 或 Session 范围的 `read_usage` 授权。结果不包含对话正文,缺失用量、报价和结果分别保持未知。
+
+每页最多 100 条/48 KiB。`{ kind: 'continue', cursor }` 重读该页,`nextCursor` 在相同筛选条件与结算快照下翻页。Host 重启使游标失效;每次读取都重新检查当前授权。
+
 ## 模型适配器
 
 `ctx.modelAdapters.register(name, open)` 注册协议适配器。`open('request' | 'conversation')` 返回 `stream(request, context)` 和可选的 `confirm(history)`。Rust 使用 `maka_plugins::model::ProviderAdapter`,共享类型化事件、HTTP 与 WebSocket 契约。模型 override 的 `adapter` 指定贡献名称;默认名称为 `responses`、`chat-completions`、`anthropic-messages`。
diff --git a/packages/plugin-sdk/src/authorization.ts b/packages/plugin-sdk/src/authorization.ts
index f5caaaa847..c047a5702c 100644
--- a/packages/plugin-sdk/src/authorization.ts
+++ b/packages/plugin-sdk/src/authorization.ts
@@ -27,7 +27,8 @@ export type AuthorizationCapability =
   | 'executions'
   | 'notifications'
   | 'read_sessions'
-  | 'read_history';
+  | 'read_history'
+  | 'read_usage';
 export type AuthorizationScope = 'profile' | `session:${string}`;
 export interface AuthorizationRequest {
   /** Retain across lost replies; changing the proposal requires a new UUID. */
diff --git a/packages/plugin-sdk/src/host.ts b/packages/plugin-sdk/src/host.ts
index 5fdd9e9d3f..23e609d649 100644
--- a/packages/plugin-sdk/src/host.ts
+++ b/packages/plugin-sdk/src/host.ts
@@ -36,6 +36,7 @@ export type * from './models.js';
 export type * from './providers.js';
 export type * from './clients.js';
 export type * from './history.js';
+export type * from './usage.js';
 
 /** Independent API version, used by runtime.sdkVersion in maka.extension.json. */
 export const HOST_SDK_VERSION = 1;
@@ -135,6 +136,7 @@ export interface ResourceContext {
   };
   /** Trusted Agent recall reads the Host profile; independent scopes use read_history. */
   readonly history: import('./history.js').History;
+  readonly usage: import('./usage.js').Usage;
   readonly files: Files;
   readonly llm: import('./llm.js').Llm;
   readonly clients: import('./clients.js').ClientCapabilities;
diff --git a/packages/plugin-sdk/src/usage.ts b/packages/plugin-sdk/src/usage.ts
new file mode 100644
index 0000000000..70b6da6629
--- /dev/null
+++ b/packages/plugin-sdk/src/usage.ts
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+
+import type { Invocation } from './execution.js';
+import type { ModelGeneration, ModelChoice } from './llm.js';
+
+export interface ModelAttempt {
+  requestId: string;
+  origin:
+    | { kind: 'agent'; invocation: Invocation; purpose: 'main' | 'compaction' }
+    | {
+        kind: 'auxiliary';
+        source:
+          | { kind: 'agent'; invocation: Invocation; operation_id: string }
+          | { kind: 'host_effect'; id: string };
+      };
+  binding: ModelChoice['model'] | null;
+  sessionId: string | null;
+  modelId: string;
+  startedAt: number;
+  completedAt: number;
+  outcome: 'success' | 'error' | 'aborted' | 'unknown';
+  usage: ModelGeneration['usage'];
+  quote: {
+    providerId: string;
+    revision: number;
+    pricing: {
+      modelKey: string;
+      inputUsdPer1M: number;
+      outputUsdPer1M: number;
+      cacheReadUsdPer1M?: number;
+      cacheWriteUsdPer1M?: number;
+    } | null;
+  } | null;
+  costUsd: number | null;
+}
+
+export interface UsagePage {
+  /** Re-read the same page. Cursors expire on Host restart and confer no access. */
+  cursor: string;
+  nextCursor: string | null;
+  attempts: readonly ModelAttempt[];
+  total: number;
+}
+
+export type UsageRead =
+  | { kind: 'start'; filter: { from: number; to: number; sessionId?: string | null } }
+  | { kind: 'continue'; cursor: string };
+
+export interface Usage {
+  /** Settled physical calls, including failed retries and auxiliary SDK calls.
+   * At most 100 rows / 48 KiB. Missing counters or rates remain unknown.
+   * Agent calls see their Session; independent calls need read_usage consent
+   * with a profile or Session target. No conversation bodies are exposed.
+   */
+  models(input: UsageRead): Promise<UsagePage>;
+}
diff --git a/packages/runtime-host/src/protocol/plugin-authorization.ts b/packages/runtime-host/src/protocol/plugin-authorization.ts
index 41de39cc50..2704039d9c 100644
--- a/packages/runtime-host/src/protocol/plugin-authorization.ts
+++ b/packages/runtime-host/src/protocol/plugin-authorization.ts
@@ -57,6 +57,7 @@ const capabilities: readonly AuthorizationCapability[] = [
   'notifications',
   'read_sessions',
   'read_history',
+  'read_usage',
 ];
 
 export const PLUGIN_AUTHORIZATION_OPERATION_SPECS = {
@@ -191,7 +192,8 @@ function proposal(value: unknown): AuthorizationRequest {
           (capability) =>
             capability !== 'notifications' &&
             capability !== 'read_sessions' &&
-            capability !== 'read_history',
+            capability !== 'read_history' &&
+            capability !== 'read_usage',
         )
       )
         throw invalidProtocolFrame('Profile has no workspace authority');
@@ -229,6 +231,12 @@ function proposal(value: unknown): AuthorizationRequest {
     default:
       throw invalidProtocolFrame('Invalid authorization target');
   }
+  if (
+    requested.includes('read_usage') &&
+    resolved.kind !== 'profile' &&
+    resolved.kind !== 'session'
+  )
+    throw invalidProtocolFrame('Usage authorization requires a profile or Session target');
   return { operationId: uuid(row.operationId), title, capabilities: requested, target: resolved };
 }
 function authorizationMode(value: unknown, requested: AuthorizationRequest['capabilities']) {


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


(maka) 02/09: feat(usage): persist and project physical model accounting

Posted by wy...@apache.org.
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

commit 4b857748f4c36aa7a249a87587d8d856c6320ac4
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 15:12:12 2026 +0800

    feat(usage): persist and project physical model accounting
    
    Generated-by: Codex
---
 crates/event-log/migrations/0001_runtime.sql       |  85 ++++++
 crates/event-log/src/lib.rs                        |   1 +
 crates/event-log/src/usage.rs                      | 180 +++++++++++++
 crates/event-log/src/usage/auxiliary.rs            | 282 +++++++++++++++++++
 crates/event-log/tests/auxiliary_usage.rs          | 145 ++++++++++
 crates/event-log/tests/usage.rs                    | 300 +++++++++++++++++++++
 .../runtime-host/src/execution/plugins/effects.rs  |  38 ++-
 .../src/execution/plugins/effects/client.rs        |  24 +-
 .../src/execution/plugins/effects/http.rs          |   2 +-
 .../src/execution/plugins/effects/notification.rs  |  28 +-
 crates/runtime-host/src/execution/plugins/llm.rs   |  77 +-----
 .../src/execution/plugins/llm/generate.rs          | 140 ++++++++++
 crates/runtime-host/src/server.rs                  |   1 +
 .../integration/javascript_plugins/metering.rs     |  43 +++
 .../tests/integration/plugin_authorization.rs      |  28 ++
 docs/rust-parity.md                                |   2 +-
 docs/rust-parity.zh-CN.md                          |   2 +-
 docs/rust-runtime.md                               |   4 +
 docs/rust-runtime.zh-CN.md                         |   3 +
 19 files changed, 1278 insertions(+), 107 deletions(-)

diff --git a/crates/event-log/migrations/0001_runtime.sql b/crates/event-log/migrations/0001_runtime.sql
index bb912a6bc2..522dc26196 100644
--- a/crates/event-log/migrations/0001_runtime.sql
+++ b/crates/event-log/migrations/0001_runtime.sql
@@ -292,6 +292,91 @@ CREATE UNIQUE INDEX operation_fact ON event_log(operation_id, kind)
 CREATE UNIQUE INDEX invocation_boundary ON event_log(invocation_id, kind)
     WHERE kind IN ('invocation_opened', 'invocation_ended');
 
+CREATE INDEX model_usage_requests ON event_log(sequence) WHERE kind = 'model_requested';
+
+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';
+
+-- Count physical admissions, not history memberships or accepted responses.
+-- A rejected response can still have provider-reported usage. No mutable
+-- Session metadata or current provider configuration participates in this view.
+CREATE VIEW agent_model_usage AS
+WITH attempts AS (
+    SELECT request.sequence, request.event_id,
+        json_extract(request.event_json, '$.invocation') AS invocation,
+        json_extract(opening.event_json, '$.fact.configuration.model') AS binding,
+        json_extract(request.event_json, '$.fact.model_id') AS model_id,
+        json_extract(request.event_json, '$.fact.purpose') AS purpose,
+        json_extract(request.event_json, '$.recorded_at') AS started,
+        COALESCE(json_extract(completed.event_json, '$.recorded_at'),
+                 json_extract(interrupted.event_json, '$.recorded_at'),
+                 json_extract(terminal.event_json, '$.recorded_at')) AS ended,
+        CASE
+            WHEN completed.sequence IS NOT NULL THEN 'success'
+            WHEN json_extract(interrupted.event_json, '$.fact.status') = 'cancelled' THEN 'aborted'
+            WHEN interrupted.sequence IS NOT NULL THEN 'error'
+            WHEN json_extract(terminal.event_json, '$.fact.outcome.kind') = 'cancelled' THEN 'aborted'
+            WHEN terminal.sequence IS NOT NULL THEN 'unknown'
+        END AS outcome,
+        COALESCE(json_extract(completed.event_json, '$.fact.output.usage'), (
+            SELECT json_extract(observed.event_json, '$.fact.event.data.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 observed.invocation_id = request.invocation_id
+              AND json_extract(observed.event_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
+        AND opening.kind = 'invocation_opened'
+    LEFT JOIN runtime_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
+        AND interrupted.kind = 'model_interrupted'
+    LEFT JOIN runtime_events terminal ON terminal.invocation_id = request.invocation_id
+        AND terminal.kind = 'invocation_ended'
+    WHERE request.kind = 'model_requested'
+)
+SELECT sequence, event_id,
+    json_object('kind', 'agent', 'invocation', json(invocation), 'purpose', purpose) AS origin,
+    binding, model_id, outcome, usage,
+    json_extract(invocation, '$.session_id') AS session_id,
+    json_extract(started, '$.secs_since_epoch') * 1000.0
+        + json_extract(started, '$.nanos_since_epoch') / 1000000.0 AS started_at,
+    json_extract(ended, '$.secs_since_epoch') * 1000.0
+        + json_extract(ended, '$.nanos_since_epoch') / 1000000.0 AS completed_at
+FROM attempts;
+
+CREATE UNIQUE INDEX auxiliary_model_source ON event_log(json_extract(event_json, '$.source'))
+    WHERE kind = 'auxiliary_model_started';
+CREATE UNIQUE INDEX auxiliary_model_outcome ON event_log(
+    json_extract(event_json, '$.request_id'), kind
+) WHERE kind IN ('auxiliary_model_settled', 'auxiliary_model_usage');
+
+CREATE VIEW auxiliary_model_usage AS
+SELECT started.sequence, started.event_id,
+    json_object('kind', 'auxiliary', 'source', json_extract(started.event_json, '$.source')) AS origin,
+    json_extract(started.event_json, '$.binding') AS binding,
+    json_extract(started.event_json, '$.binding.model') AS model_id,
+    json_extract(settled.event_json, '$.outcome') AS outcome,
+    COALESCE(json_extract(observed.event_json, '$.usage'), '{}') AS usage,
+    json_extract(started.event_json, '$.session_id') AS session_id,
+    json_extract(started.event_json, '$.started_at.secs_since_epoch') * 1000.0
+        + json_extract(started.event_json, '$.started_at.nanos_since_epoch') / 1000000.0 AS started_at,
+    json_extract(settled.event_json, '$.completed_at.secs_since_epoch') * 1000.0
+        + json_extract(settled.event_json, '$.completed_at.nanos_since_epoch') / 1000000.0 AS completed_at
+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
+WHERE started.kind = 'auxiliary_model_started';
+
+CREATE VIEW model_usage AS
+SELECT * FROM agent_model_usage UNION ALL SELECT * FROM auxiliary_model_usage;
+
 CREATE UNIQUE INDEX message_steering_identity ON event_log(
     json_extract(event_json, '$.invocation.session_id'),
     json_extract(event_json, '$.fact.message.message_id')
diff --git a/crates/event-log/src/lib.rs b/crates/event-log/src/lib.rs
index 79ce202419..19b72162d2 100644
--- a/crates/event-log/src/lib.rs
+++ b/crates/event-log/src/lib.rs
@@ -55,6 +55,7 @@ mod tool_calls;
 mod tool_payloads;
 pub mod transcript;
 pub mod turns;
+pub mod usage;
 pub mod workspace;
 
 use std::path::Path;
diff --git a/crates/event-log/src/usage.rs b/crates/event-log/src/usage.rs
new file mode 100644
index 0000000000..a94519bda5
--- /dev/null
+++ b/crates/event-log/src/usage.rs
@@ -0,0 +1,180 @@
+/*
+ * 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.
+ */
+
+//! Accounting reads use physical source facts, never copied conversation history.
+//! Missing counters remain unknown; this layer makes no pricing assumptions.
+
+use crate::{EventLog, StoreError};
+use maka_runtime::{
+    context::ModelPurpose, event::Invocation, execution::ModelBinding, model::ModelUsage,
+};
+use serde::{Deserialize, Serialize};
+use sqlx::{Connection, Row, sqlite::SqliteRow};
+
+mod auxiliary;
+pub use auxiliary::Source as AuxiliarySource;
+
+#[derive(Clone, Debug)]
+pub struct Query {
+    /// Inclusive Unix milliseconds; fractional milliseconds are not rounded.
+    pub from: f64,
+    pub to: f64,
+    pub session_id: Option<String>,
+}
+
+impl Query {
+    fn validate(&self) -> Result<(), StoreError> {
+        if !self.from.is_finite() || !self.to.is_finite() || self.from < 0.0 || self.to < self.from
+        {
+            return Err(invalid("invalid Usage time range"));
+        }
+        if let Some(id) = &self.session_id {
+            crate::sessions::validate_id(id)?;
+        }
+        Ok(())
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Outcome {
+    Success,
+    Error,
+    Aborted,
+    /// The invocation ended without a recorded outcome for this admission.
+    Unknown,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ModelAttempt {
+    pub request_id: String,
+    pub origin: Origin,
+    /// Frozen public identity; absent when the invocation had no binding.
+    pub binding: Option<ModelBinding>,
+    pub model_id: String,
+    pub started_at: f64,
+    pub completed_at: f64,
+    pub outcome: Outcome,
+    pub usage: ModelUsage,
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Origin {
+    Agent {
+        invocation: Invocation,
+        purpose: ModelPurpose,
+    },
+    Auxiliary {
+        source: AuxiliarySource,
+    },
+}
+
+impl ModelAttempt {
+    /// Wall-clock adjustments cannot produce negative reported latency.
+    pub fn latency_ms(&self) -> f64 {
+        (self.completed_at - self.started_at).max(0.0)
+    }
+}
+
+pub struct ModelPage {
+    pub attempts: Vec<ModelAttempt>,
+    pub total: u64,
+    pub next_offset: Option<u64>,
+}
+
+impl EventLog {
+    /// Bounded, settled model admissions (Agent inference, compaction and Host SDK).
+    /// Includes failed physical retries and provider usage from rejected output.
+    /// Session removal retains these canonical facts; copies do not duplicate them.
+    pub async fn model_attempts(
+        &self,
+        query: Query,
+        offset: u64,
+        limit: u32,
+    ) -> Result<ModelPage, StoreError> {
+        self.validate_root()?;
+        query.validate()?;
+        if offset > i64::MAX as u64 || !(1..=100).contains(&limit) {
+            return Err(invalid("invalid Usage page"));
+        }
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    let total: i64 = sqlx::query_scalar(
+                        "SELECT COUNT(*) FROM model_usage
+                 WHERE completed_at >= ?1 AND completed_at <= ?2
+                 AND (?3 IS NULL OR session_id = ?3)",
+                    )
+                    .bind(query.from)
+                    .bind(query.to)
+                    .bind(&query.session_id)
+                    .fetch_one(&mut *tx)
+                    .await?;
+                    let rows = sqlx::query(
+                        "SELECT event_id, origin, binding, model_id,
+                        started_at, completed_at, outcome, usage
+                 FROM model_usage
+                 WHERE completed_at >= ?1 AND completed_at <= ?2
+                 AND (?3 IS NULL OR session_id = ?3)
+                 ORDER BY completed_at DESC, sequence DESC LIMIT ?4 OFFSET ?5",
+                    )
+                    .bind(query.from)
+                    .bind(query.to)
+                    .bind(query.session_id)
+                    .bind(limit)
+                    .bind(offset as i64)
+                    .fetch_all(&mut *tx)
+                    .await?;
+                    let attempts = rows.iter().map(read).collect::<Result<Vec<_>, _>>()?;
+                    let total = crate::sequence_number(total)?;
+                    let end = offset + attempts.len() as u64;
+                    tx.commit().await?;
+                    Ok(ModelPage {
+                        attempts,
+                        total,
+                        next_offset: (end < total).then_some(end),
+                    })
+                })
+            })
+            .await
+    }
+}
+
+fn read(row: &SqliteRow) -> Result<ModelAttempt, StoreError> {
+    let outcome: String = row.try_get("outcome")?;
+    Ok(ModelAttempt {
+        request_id: row.try_get("event_id")?,
+        origin: serde_json::from_str(row.try_get("origin")?)?,
+        binding: row
+            .try_get::<Option<&str>, _>("binding")?
+            .map(serde_json::from_str)
+            .transpose()?,
+        model_id: row.try_get("model_id")?,
+        started_at: row.try_get("started_at")?,
+        completed_at: row.try_get("completed_at")?,
+        outcome: serde_json::from_value(serde_json::Value::String(outcome))?,
+        usage: serde_json::from_str(row.try_get("usage")?)?,
+    })
+}
+
+fn invalid(message: &str) -> StoreError {
+    StoreError::InvalidTransition(message.into())
+}
diff --git a/crates/event-log/src/usage/auxiliary.rs b/crates/event-log/src/usage/auxiliary.rs
new file mode 100644
index 0000000000..c888a20237
--- /dev/null
+++ b/crates/event-log/src/usage/auxiliary.rs
@@ -0,0 +1,282 @@
+/*
+ * 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 super::{Outcome, invalid};
+use crate::{EventLog, StoreError};
+use maka_runtime::{event::Invocation, execution::ModelBinding, model::ModelUsage};
+use serde::{Deserialize, Serialize};
+use sqlx::{Connection, SqliteConnection};
+use std::time::SystemTime;
+use uuid::Uuid;
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum Source {
+    Agent {
+        invocation: Invocation,
+        operation_id: String,
+    },
+    HostEffect {
+        id: Uuid,
+    },
+}
+
+#[derive(Serialize, Deserialize)]
+pub(super) struct Admission {
+    source: Source,
+    binding: ModelBinding,
+    session_id: Option<String>,
+    started_at: SystemTime,
+}
+
+#[derive(Serialize)]
+struct Settlement {
+    request_id: Uuid,
+    outcome: Outcome,
+    completed_at: SystemTime,
+}
+
+#[derive(Serialize)]
+struct Observation {
+    request_id: Uuid,
+    usage: ModelUsage,
+}
+
+impl EventLog {
+    /// Called inside an already-admitted Host SDK effect, before model dispatch.
+    /// The source journal supplies the binding; plugins cannot invent accounting
+    /// identity by supplying a Session ID or current provider configuration.
+    pub async fn begin_auxiliary_model(&self, source: Source) -> Result<Uuid, StoreError> {
+        self.validate_root()?;
+        let commits = self.commits.clone();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    let (binding, session_id) = binding(&mut tx, &source).await?;
+                    let record = Admission {
+                        source,
+                        binding,
+                        session_id,
+                        started_at: SystemTime::now(),
+                    };
+                    let id = Uuid::new_v4();
+                    let sequence = insert(&mut tx, id, "auxiliary_model_started", &record).await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    commits.send_replace(sequence);
+                    Ok(id)
+                })
+            })
+            .await
+    }
+
+    /// Provider-reported usage is durable even if later semantic validation fails.
+    pub async fn observe_auxiliary_model(
+        &self,
+        id: Uuid,
+        usage: ModelUsage,
+    ) -> Result<(), StoreError> {
+        self.validate_root()?;
+        let commits = self.commits.clone();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    require_pending(&mut tx, id).await?;
+                    let sequence = insert(
+                        &mut tx,
+                        Uuid::new_v4(),
+                        "auxiliary_model_usage",
+                        &Observation {
+                            request_id: id,
+                            usage,
+                        },
+                    )
+                    .await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    commits.send_replace(sequence);
+                    Ok(())
+                })
+            })
+            .await
+    }
+
+    pub async fn settle_auxiliary_model(
+        &self,
+        id: Uuid,
+        outcome: Outcome,
+    ) -> Result<(), StoreError> {
+        self.validate_root()?;
+        let commits = self.commits.clone();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    require_pending(&mut tx, id).await?;
+                    let sequence = settle(&mut tx, id, outcome).await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    commits.send_replace(sequence);
+                    Ok(())
+                })
+            })
+            .await
+    }
+
+    /// Exclusive Host startup only; no in-memory provider request survives its
+    /// previous owner. A missing result is unknown, never free or safe to replay.
+    pub async fn recover_auxiliary_models(&self) -> Result<(), StoreError> {
+        self.validate_root()?;
+        loop {
+            let commits = self.commits.clone();
+            let count = self.connection.run(move |connection| Box::pin(async move {
+                let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                let ids: Vec<String> = sqlx::query_scalar(
+                    "SELECT event_id FROM event_log started
+                     WHERE kind = 'auxiliary_model_started' AND NOT EXISTS (
+                        SELECT 1 FROM event_log settled WHERE settled.kind = 'auxiliary_model_settled'
+                        AND json_extract(settled.event_json, '$.request_id') = started.event_id)
+                     ORDER BY sequence LIMIT 128"
+                ).fetch_all(&mut *tx).await?;
+                let mut last = None;
+                for id in &ids {
+                    last = Some(settle(&mut tx, Uuid::parse_str(id)
+                        .map_err(|_| invalid("invalid auxiliary request identity"))?, Outcome::Unknown).await?);
+                }
+                tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                if let Some(sequence) = last { commits.send_replace(sequence); }
+                Ok(ids.len())
+            })).await?;
+            if count < 128 {
+                return Ok(());
+            }
+        }
+    }
+}
+
+async fn binding(
+    connection: &mut SqliteConnection,
+    source: &Source,
+) -> Result<(ModelBinding, Option<String>), StoreError> {
+    match source {
+        Source::Agent {
+            invocation,
+            operation_id,
+        } => {
+            let raw: Option<String> = sqlx::query_scalar(
+                "SELECT opening.event_json FROM runtime_events opening
+                 JOIN runtime_events dispatch ON dispatch.invocation_id = opening.invocation_id
+                 WHERE opening.kind = 'invocation_opened' AND opening.invocation_id = ?1
+                 AND dispatch.kind = 'tool_dispatched' AND dispatch.operation_id = ?2
+                 AND json_extract(dispatch.event_json, '$.fact.name') = 'llm.generate'
+                 AND json_extract(dispatch.event_json, '$.fact.call.origin.kind') = 'host_sdk'
+                 AND NOT EXISTS (SELECT 1 FROM runtime_events settled
+                    WHERE (settled.kind = 'tool_settled' AND settled.operation_id = ?2)
+                       OR (settled.kind = 'invocation_ended' AND settled.invocation_id = ?1))",
+            )
+            .bind(&invocation.invocation_id)
+            .bind(operation_id)
+            .fetch_optional(connection)
+            .await?;
+            let event: maka_runtime::event::RuntimeEvent = serde_json::from_str(
+                &raw.ok_or_else(|| invalid("auxiliary model source is not pending"))?,
+            )?;
+            if event.invocation != *invocation {
+                return Err(invalid("auxiliary model invocation changed"));
+            }
+            match event.fact {
+                maka_runtime::event::Fact::InvocationOpened {
+                    configuration: Some(config),
+                    ..
+                } => config
+                    .model
+                    .map(|binding| (binding, Some(invocation.session_id.clone())))
+                    .ok_or_else(|| invalid("auxiliary model has no frozen binding")),
+                _ => Err(invalid("auxiliary model has no frozen configuration")),
+            }
+        }
+        Source::HostEffect { id } => {
+            let raw: Option<String> = sqlx::query_scalar(
+                "SELECT request FROM host_effects WHERE id = ? AND outcome IS NULL",
+            )
+            .bind(id.to_string())
+            .fetch_optional(connection)
+            .await?;
+            let request: crate::effects::Request = serde_json::from_str(
+                &raw.ok_or_else(|| invalid("auxiliary Host effect is not pending"))?,
+            )?;
+            let session = match request.boundary {
+                maka_plugins::authorization::Boundary::Session { boundary, .. } => {
+                    Some(boundary.session_id)
+                }
+                _ => None,
+            };
+            match request.operation {
+                crate::effects::Operation::Model { model, .. } => Ok((model, session)),
+                _ => Err(invalid("Host effect is not a model operation")),
+            }
+        }
+    }
+}
+
+async fn require_pending(connection: &mut SqliteConnection, id: Uuid) -> Result<(), StoreError> {
+    let pending: bool = sqlx::query_scalar(
+        "SELECT EXISTS(SELECT 1 FROM event_log WHERE event_id = ?1 AND kind = 'auxiliary_model_started')
+         AND NOT EXISTS(SELECT 1 FROM event_log WHERE kind = 'auxiliary_model_settled'
+             AND json_extract(event_json, '$.request_id') = ?1)"
+    ).bind(id.to_string()).fetch_one(connection).await?;
+    if pending {
+        Ok(())
+    } else {
+        Err(invalid("auxiliary model request is not pending"))
+    }
+}
+
+async fn settle(
+    connection: &mut SqliteConnection,
+    id: Uuid,
+    outcome: Outcome,
+) -> Result<u64, StoreError> {
+    insert(
+        connection,
+        Uuid::new_v4(),
+        "auxiliary_model_settled",
+        &Settlement {
+            request_id: id,
+            outcome,
+            completed_at: SystemTime::now(),
+        },
+    )
+    .await
+}
+
+async fn insert(
+    connection: &mut SqliteConnection,
+    id: Uuid,
+    kind: &str,
+    record: &impl Serialize,
+) -> Result<u64, StoreError> {
+    let inserted =
+        sqlx::query("INSERT INTO event_log(event_id, kind, event_json) VALUES (?, ?, ?)")
+            .bind(id.to_string())
+            .bind(kind)
+            .bind(serde_json::to_string(record)?)
+            .execute(connection)
+            .await?;
+    crate::sequence_number(inserted.last_insert_rowid())
+}
diff --git a/crates/event-log/tests/auxiliary_usage.rs b/crates/event-log/tests/auxiliary_usage.rs
new file mode 100644
index 0000000000..4fe278c00b
--- /dev/null
+++ b/crates/event-log/tests/auxiliary_usage.rs
@@ -0,0 +1,145 @@
+/*
+ * 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,
+    effects::{Operation, Outcome as EffectOutcome, Request},
+    usage::{AuxiliarySource, Origin, Outcome, Query},
+};
+use maka_plugins::{authorization::Boundary, call::Identity, fiber, llm::Generate};
+use maka_runtime::{execution::ModelBinding, model::ModelUsage, scope::Scope};
+use uuid::Uuid;
+
+fn request() -> Request {
+    Request {
+        source: Identity::Remote {
+            request_id: Uuid::new_v4(),
+        },
+        owner: fiber::Identity {
+            package_id: "external-model-client".into(),
+            entry_id: "entry".into(),
+            scope: Scope::Profile,
+            activation: "activation".into(),
+            generation: 1,
+        },
+        boundary: Boundary::Profile,
+        operation: Operation::Model {
+            input: Generate {
+                prompt: "work".into(),
+                system: None,
+                max_output_tokens: None,
+            },
+            model: ModelBinding {
+                connection_id: "connection".into(),
+                connection_slug: "named-connection".into(),
+                model: "chosen-model".into(),
+            },
+            thinking_level: None,
+        },
+    }
+}
+
+fn query() -> Query {
+    Query {
+        from: 0.0,
+        to: f64::MAX,
+        session_id: None,
+    }
+}
+
+#[tokio::test]
+async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_failed_recovery() {
+    let directory = tempfile::tempdir().unwrap();
+    let path = directory.path().join("auxiliary.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    assert!(
+        log.begin_auxiliary_model(AuxiliarySource::HostEffect { id: Uuid::new_v4() })
+            .await
+            .is_err()
+    );
+    let parent = log.begin_host_effect(request()).await.unwrap();
+    let source = AuxiliarySource::HostEffect { id: parent };
+    let id = log.begin_auxiliary_model(source.clone()).await.unwrap();
+    assert!(
+        log.begin_auxiliary_model(source.clone()).await.is_err(),
+        "one accepted generation cannot be physically dispatched twice"
+    );
+    let usage = ModelUsage {
+        input_tokens: Some(77),
+        ..Default::default()
+    };
+    log.observe_auxiliary_model(id, usage.clone())
+        .await
+        .unwrap();
+    assert_eq!(log.model_attempts(query(), 0, 100).await.unwrap().total, 0);
+    log.settle_auxiliary_model(id, Outcome::Error)
+        .await
+        .unwrap();
+    log.settle_host_effect(
+        parent,
+        EffectOutcome::Failed {
+            message: "invalid model output".into(),
+        },
+        None,
+    )
+    .await
+    .unwrap();
+    assert!(
+        log.observe_auxiliary_model(id, ModelUsage::default())
+            .await
+            .is_err()
+    );
+    assert!(
+        log.settle_auxiliary_model(id, Outcome::Success)
+            .await
+            .is_err()
+    );
+    assert!(log.begin_auxiliary_model(source.clone()).await.is_err());
+    let before = log.model_attempts(query(), 0, 100).await.unwrap();
+    assert_eq!(before.total, 1);
+    let attempt = &before.attempts[0];
+    assert_eq!(attempt.origin, Origin::Auxiliary { source });
+    assert_eq!(attempt.outcome, Outcome::Error);
+    assert_eq!(attempt.usage, usage);
+    let binding = attempt.binding.as_ref().unwrap();
+    assert_eq!(binding.connection_slug, "named-connection");
+    assert_eq!(binding.model, "chosen-model");
+
+    // Crash after usage arrives but before the effect or model outcome commits.
+    let parent = log.begin_host_effect(request()).await.unwrap();
+    let interrupted = log
+        .begin_auxiliary_model(AuxiliarySource::HostEffect { id: parent })
+        .await
+        .unwrap();
+    log.observe_auxiliary_model(interrupted, usage.clone())
+        .await
+        .unwrap();
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    log.recover_host_effects().await.unwrap();
+    log.recover_auxiliary_models().await.unwrap();
+    log.recover_auxiliary_models().await.unwrap();
+    let after = log.model_attempts(query(), 0, 100).await.unwrap();
+    assert_eq!(after.total, 2);
+    assert_eq!(after.attempts[0].request_id, interrupted.to_string());
+    assert_eq!(after.attempts[0].outcome, Outcome::Unknown);
+    assert_eq!(after.attempts[0].usage, usage);
+    assert_eq!(after.attempts[1], *attempt);
+    log.close().await.unwrap();
+}
diff --git a/crates/event-log/tests/usage.rs b/crates/event-log/tests/usage.rs
new file mode 100644
index 0000000000..c51e4d83f5
--- /dev/null
+++ b/crates/event-log/tests/usage.rs
@@ -0,0 +1,300 @@
+/*
+ * 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,
+    sessions::{SessionCopy, SessionCopyResult, SessionRetirement},
+    usage::{Outcome, Query},
+};
+use maka_runtime::{
+    context::ModelPurpose,
+    event::{
+        EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, LogScope,
+        ModelInterruption, RuntimeEvent,
+    },
+    model::{ModelEvent, ModelFinishReason, ModelStep, ModelUsage},
+    session::CopyPurpose,
+};
+use serde_json::json;
+use std::time::{Duration, UNIX_EPOCH};
+
+fn event(fact: Fact, micros: u64) -> EventWrite {
+    let mut event = RuntimeEvent::new(
+        Invocation {
+            session_id: "source".into(),
+            turn_id: "turn".into(),
+            run_id: "run".into(),
+            invocation_id: "invocation".into(),
+        },
+        fact,
+    );
+    event.recorded_at = UNIX_EPOCH + Duration::from_micros(micros);
+    EventWrite::plain(event).unwrap()
+}
+
+fn request(id: &str, micros: u64) -> EventWrite {
+    event(
+        Fact::ModelRequested {
+            step_id: id.into(),
+            model_id: "model".into(),
+            purpose: ModelPurpose::Main,
+            source_scope: LogScope::Session {
+                id: "source".into(),
+            },
+            source_high_water: 0,
+            source_digest: "fixture".into(),
+            input_digest: "frozen-input".into(),
+            route_identity: "frozen-route".into(),
+            checkpoint_event_id: None,
+            context: None,
+            effective_source_digest: None,
+        },
+        micros,
+    )
+}
+
+fn query() -> Query {
+    Query {
+        from: 0.0,
+        to: 100.0,
+        session_id: None,
+    }
+}
+
+#[tokio::test]
+async fn physical_attempt_usage_survives_rejection_copy_removal_and_reopen() {
+    let directory = tempfile::tempdir().unwrap();
+    let path = directory.path().join("usage.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    log.create_session("source", "fixture", &json!({}), 1)
+        .await
+        .unwrap();
+    let rejected = request("rejected", 1000);
+    let retry = request("retry", 2000);
+    let success = request("success", 3000);
+    let reported = ModelUsage {
+        input_tokens: Some(100),
+        output_tokens: Some(20),
+        cache_read_tokens: Some(80),
+        ..Default::default()
+    };
+    let writes = [
+        event(
+            Fact::InvocationOpened {
+                input: InvocationInput::Message {
+                    content: "question".into(),
+                    source_messages: vec![],
+                    request_fingerprint: None,
+                },
+                configuration: None,
+            },
+            0,
+        ),
+        rejected.clone(),
+        event(
+            Fact::ModelObserved {
+                step_id: "rejected".into(),
+                event: ModelEvent::Finished {
+                    reason: ModelFinishReason::Length,
+                    usage: reported.clone(),
+                    provider_options: None,
+                },
+            },
+            10000,
+        ),
+        event(
+            Fact::ModelInterrupted {
+                step_id: "rejected".into(),
+                status: ModelInterruption::Failed,
+            },
+            10250,
+        ),
+        retry.clone(),
+        event(
+            Fact::ModelInterrupted {
+                step_id: "retry".into(),
+                status: ModelInterruption::RetryableFailure,
+            },
+            11500,
+        ),
+        success.clone(),
+        event(
+            Fact::ModelCompleted {
+                step_id: "success".into(),
+                output: ModelStep {
+                    parts: vec![],
+                    finish_reason: ModelFinishReason::Stop,
+                    usage: ModelUsage {
+                        input_tokens: Some(0),
+                        ..Default::default()
+                    },
+                    provider_options: None,
+                    response_id: None,
+                    model: None,
+                    timestamp: None,
+                },
+            },
+            11500,
+        ),
+    ];
+    log.append_batch(&writes).await.unwrap();
+    let terminal = event(
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::Completed,
+        },
+        12750,
+    );
+    log.append(&terminal).await.unwrap();
+    log.append(&terminal).await.unwrap(); // Exact replay cannot count a second call.
+    let first = log.model_attempts(query(), 0, 2).await.unwrap();
+    assert_eq!(first.total, 3);
+    assert_eq!(first.next_offset, Some(2));
+    assert_eq!(first.attempts[0].request_id, success.event().id);
+    assert_eq!(first.attempts[0].usage.input_tokens, Some(0));
+    assert_eq!(first.attempts[0].usage.output_tokens, None);
+    assert_eq!(first.attempts[1].request_id, retry.event().id);
+    assert_eq!(first.attempts[1].outcome, Outcome::Error);
+    assert_eq!(first.attempts[1].usage, ModelUsage::default());
+    let second = log.model_attempts(query(), 2, 2).await.unwrap();
+    assert_eq!(second.next_offset, None);
+    assert_eq!(second.attempts[0].request_id, rejected.event().id);
+    assert_eq!(second.attempts[0].outcome, Outcome::Error);
+    assert_eq!(second.attempts[0].usage, reported);
+    assert_eq!(second.attempts[0].completed_at, 10.25);
+    assert!(
+        second.attempts[0].binding.is_none(),
+        "do not invent provider identity"
+    );
+    let mut fractional = query();
+    fractional.from = 10.25;
+    fractional.to = 10.25;
+    assert_eq!(
+        log.model_attempts(fractional.clone(), 0, 100)
+            .await
+            .unwrap()
+            .total,
+        1
+    );
+    fractional.from = 10.251;
+    fractional.to = 100.0;
+    assert_eq!(
+        log.model_attempts(fractional, 0, 100).await.unwrap().total,
+        2
+    );
+
+    let revision = log
+        .get_session::<serde_json::Value>("source")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    assert!(matches!(
+        log.copy_session(
+            SessionCopy {
+                source_session_id: "source".into(),
+                target_session_id: "branch".into(),
+                expected_source_revision: revision,
+                purpose: CopyPurpose::Branch {
+                    turn_id: None,
+                    side_conversation: false
+                },
+            },
+            &json!({}),
+            2
+        )
+        .await
+        .unwrap(),
+        SessionCopyResult::Committed(_)
+    ));
+    let snapshot = log.model_attempts(query(), 0, 100).await.unwrap().attempts;
+    assert_eq!(
+        snapshot.len(),
+        3,
+        "history membership is not another physical request"
+    );
+    let mut branch = query();
+    branch.session_id = Some("branch".into());
+    assert_eq!(log.model_attempts(branch, 0, 100).await.unwrap().total, 0);
+
+    log.begin_session_removal("source", revision).await.unwrap();
+    assert_eq!(
+        log.finish_session_retirement("source").await.unwrap(),
+        SessionRetirement::Removed
+    );
+    assert_eq!(
+        log.model_attempts(query(), 0, 100).await.unwrap().attempts,
+        snapshot
+    );
+    log.close().await.unwrap();
+    let reopened = EventLog::open(&path).await.unwrap();
+    assert_eq!(
+        reopened
+            .model_attempts(query(), 0, 100)
+            .await
+            .unwrap()
+            .attempts,
+        snapshot
+    );
+    reopened.close().await.unwrap();
+}
+#[tokio::test]
+async fn lost_request_outcome_is_unknown_not_a_success_with_zero_usage() {
+    let directory = tempfile::tempdir().unwrap();
+    let log = EventLog::open(&directory.path().join("lost.sqlite"))
+        .await
+        .unwrap();
+    log.append_batch(&[
+        event(
+            Fact::InvocationOpened {
+                configuration: None,
+                input: InvocationInput::Message {
+                    content: "question".into(),
+                    source_messages: vec![],
+                    request_fingerprint: None,
+                },
+            },
+            0,
+        ),
+        request("lost", 13000),
+    ])
+    .await
+    .unwrap();
+    assert_eq!(log.model_attempts(query(), 0, 100).await.unwrap().total, 0);
+    log.append(&event(
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::Failed {
+                class: "worker_lost".into(),
+                message: None,
+            },
+        },
+        12750,
+    ))
+    .await
+    .unwrap();
+    let page = log.model_attempts(query(), 0, 100).await.unwrap();
+    assert_eq!(page.total, 1);
+    assert_eq!(page.attempts[0].outcome, Outcome::Unknown);
+    assert_eq!(page.attempts[0].usage, ModelUsage::default());
+    assert_eq!(
+        page.attempts[0].latency_ms(),
+        0.0,
+        "wall clock moved backwards"
+    );
+    log.close().await.unwrap();
+}
diff --git a/crates/runtime-host/src/execution/plugins/effects.rs b/crates/runtime-host/src/execution/plugins/effects.rs
index 22c1569581..48601dbf73 100644
--- a/crates/runtime-host/src/execution/plugins/effects.rs
+++ b/crates/runtime-host/src/execution/plugins/effects.rs
@@ -39,7 +39,7 @@ use tokio_util::sync::CancellationToken;
 struct Prepared<T> {
     operation: Operation,
     capability: Capability,
-    effect: BoxFuture<'static, Result<T, ToolError>>,
+    effect: Box<dyn FnOnce(uuid::Uuid) -> BoxFuture<'static, Result<T, ToolError>> + Send>,
 }
 type Evidence = (Outcome, Option<Vec<u8>>);
 trait Recorded {
@@ -114,7 +114,7 @@ impl Executions {
             Prepared {
                 operation: Operation::File(input),
                 capability,
-                effect,
+                effect: Box::new(move |_| effect),
             },
             cancellation,
         )
@@ -178,8 +178,14 @@ impl Executions {
         let result = if cancellation.is_cancelled() {
             Err(failed("cancelled before effect"))
         } else {
-            prepared.effect.await
+            (prepared.effect)(id).await
         };
+        if matches!(result, Err(ToolError::Persistence(_))) {
+            // A nested canonical commit is uncertain. Preserve the pending
+            // outer effect for exclusive recovery; do not issue another write.
+            self.begin_drain();
+            return result;
+        }
         let (outcome, payload) = match &result {
             Ok(value) => value.evidence().inspect_err(|_| {
                 self.begin_drain();
@@ -419,17 +425,21 @@ impl Executions {
                 thinking_level: thinking,
             },
             capability: Capability::Models,
-            effect: Box::pin(async move {
-                let provider = provider
-                    .admit(&host.oauth)
-                    .map_err(|error| failed(error.message))?;
-                super::llm::generate(
-                    models,
-                    super::llm::request(provider, input),
-                    adapter,
-                    cancellation,
-                )
-                .await
+            effect: Box::new(move |id| {
+                Box::pin(async move {
+                    let provider = provider
+                        .admit(&host.oauth)
+                        .map_err(|error| failed(error.message))?;
+                    super::llm::generate(
+                        models,
+                        super::llm::request(provider, input),
+                        adapter,
+                        cancellation,
+                        host.log.clone(),
+                        maka_event_log::usage::AuxiliarySource::HostEffect { id },
+                    )
+                    .await
+                })
             }),
         })
     }
diff --git a/crates/runtime-host/src/execution/plugins/effects/client.rs b/crates/runtime-host/src/execution/plugins/effects/client.rs
index f56df6092f..5b04c96230 100644
--- a/crates/runtime-host/src/execution/plugins/effects/client.rs
+++ b/crates/runtime-host/src/execution/plugins/effects/client.rs
@@ -201,17 +201,19 @@ impl Executions {
             Prepared {
                 operation,
                 capability: Capability::ClientCapabilities,
-                effect: Box::pin(async move {
-                    accepted
-                        .admit()
-                        .await
-                        .map(|result| ToolOutput::Mcp(result).into_json())
-                        .map_err(|error| match error {
-                            CallError::OutcomeUnknown(_) => {
-                                ToolError::CleanupUnconfirmed(error.to_string())
-                            }
-                            error => failed(error),
-                        })
+                effect: Box::new(move |_| {
+                    Box::pin(async move {
+                        accepted
+                            .admit()
+                            .await
+                            .map(|result| ToolOutput::Mcp(result).into_json())
+                            .map_err(|error| match error {
+                                CallError::OutcomeUnknown(_) => {
+                                    ToolError::CleanupUnconfirmed(error.to_string())
+                                }
+                                error => failed(error),
+                            })
+                    })
                 }),
             },
             cancellation,
diff --git a/crates/runtime-host/src/execution/plugins/effects/http.rs b/crates/runtime-host/src/execution/plugins/effects/http.rs
index ac44f13677..7e930d5715 100644
--- a/crates/runtime-host/src/execution/plugins/effects/http.rs
+++ b/crates/runtime-host/src/execution/plugins/effects/http.rs
@@ -46,7 +46,7 @@ impl Executions {
                     Prepared {
                         operation,
                         capability: Capability::Network,
-                        effect,
+                        effect: Box::new(move |_| effect),
                     },
                     cancellation,
                 )
diff --git a/crates/runtime-host/src/execution/plugins/effects/notification.rs b/crates/runtime-host/src/execution/plugins/effects/notification.rs
index 4ede31517b..3a1c6d7e4b 100644
--- a/crates/runtime-host/src/execution/plugins/effects/notification.rs
+++ b/crates/runtime-host/src/execution/plugins/effects/notification.rs
@@ -97,19 +97,21 @@ impl Executions {
             Prepared {
                 operation,
                 capability: Capability::Notifications,
-                effect: Box::pin(async move {
-                    accepted
-                        .start()
-                        .await
-                        .map(|_| serde_json::Value::Null)
-                        .map_err(|error| match error {
-                            CallError::OutcomeUnknown(_) => {
-                                ToolError::CleanupUnconfirmed(error.to_string())
-                            }
-                            // Provider acceptance is not an assurance that a notification
-                            // was unseen. Do not automatically send it a second time.
-                            error => ToolError::OutcomeUnknown(error.to_string()),
-                        })
+                effect: Box::new(move |_| {
+                    Box::pin(async move {
+                        accepted
+                            .start()
+                            .await
+                            .map(|_| serde_json::Value::Null)
+                            .map_err(|error| match error {
+                                CallError::OutcomeUnknown(_) => {
+                                    ToolError::CleanupUnconfirmed(error.to_string())
+                                }
+                                // Provider acceptance is not an assurance that a notification
+                                // was unseen. Do not automatically send it a second time.
+                                error => ToolError::OutcomeUnknown(error.to_string()),
+                            })
+                    })
                 }),
             },
             cancellation,
diff --git a/crates/runtime-host/src/execution/plugins/llm.rs b/crates/runtime-host/src/execution/plugins/llm.rs
index a4b4a767ee..588ebab87d 100644
--- a/crates/runtime-host/src/execution/plugins/llm.rs
+++ b/crates/runtime-host/src/execution/plugins/llm.rs
@@ -19,13 +19,12 @@
 
 use super::{Error, Executions};
 use crate::execution::provider;
-use maka_model::{ModelExecutor, ModelRequest, StepBuilder, prompt::Message};
+use maka_model::{ModelRequest, prompt::Message};
 use maka_plugins::{
     fiber::Context,
     llm::{Generate, ModelGeneration},
 };
 use maka_runtime::{
-    model::{ModelEvent, ModelPart, TextKind},
     tool_call::{ToolCallIdentity, ToolOrigin},
     tool_output::ToolOutput,
     tools::{PreparedEffect, ToolError, ToolJournal},
@@ -33,6 +32,9 @@ use maka_runtime::{
 use std::sync::Arc;
 use tokio_util::sync::CancellationToken;
 
+mod generate;
+pub(super) use generate::generate;
+
 impl Executions {
     pub(crate) async fn search_plugin_models(
         &self,
@@ -228,14 +230,19 @@ impl Executions {
         )
         .map_err(|error| Error::Invalid(error.to_string()))?;
         let models = self.models.clone();
+        let operation_id = uuid::Uuid::new_v4().to_string();
+        let source = maka_event_log::usage::AuxiliarySource::Agent {
+            invocation: invocation.clone(),
+            operation_id: operation_id.clone(),
+        };
+        let log = self.log.clone();
         let effect = PreparedEffect::new(move |cancellation| {
             Box::pin(async move {
-                generate(models, request, adapter, cancellation)
+                generate(models, request, adapter, cancellation, log, source)
                     .await
                     .map(|output| ToolOutput::Model(Box::new(output)).into())
             })
         });
-        let operation_id = uuid::Uuid::new_v4().to_string();
         let journal = ToolJournal::new(self.log.clone(), invocation);
         let host = self.clone();
         let (send, receive) = tokio::sync::oneshot::channel();
@@ -305,65 +312,3 @@ pub(super) fn request(prepared: provider::PreparedProvider, input: Generate) ->
         max_output_tokens,
     }
 }
-
-pub(super) async fn generate(
-    models: ModelExecutor,
-    request: ModelRequest,
-    adapter: maka_plugins::model::Binding,
-    cancellation: CancellationToken,
-) -> Result<ModelGeneration, ToolError> {
-    let model_id = request.provider.model.clone();
-    let mut stream = models
-        .stream_with_adapter(request, cancellation, None, adapter)
-        .await
-        .map_err(failed)?;
-    let result = async {
-        let mut builder = StepBuilder::default();
-        let mut bytes = 0usize;
-        while let Some(event) = stream.next().await {
-            let event = event.map_err(failed)?;
-            bytes = bytes.saturating_add(serde_json::to_vec(&event).map_err(failed)?.len());
-            if bytes > 2 * 1024 * 1024 {
-                return Err(failed("model generation exceeds 2 MiB stream limit"));
-            }
-            if matches!(
-                event,
-                ModelEvent::ToolCall(_) | ModelEvent::ProviderToolResult { .. }
-            ) {
-                return Err(failed("auxiliary model generation cannot call tools"));
-            }
-            builder.push(event).map_err(failed)?;
-        }
-        builder.finish().map_err(failed)
-    }
-    .await;
-    // EOF alone does not release the shared worker's permits and transport.
-    stream.cancel_and_wait().await;
-    let step = result?;
-    if step.finish_reason == maka_runtime::model::ModelFinishReason::ToolCalls {
-        return Err(failed(
-            "auxiliary model generation cannot finish with tool calls",
-        ));
-    }
-    let text = step
-        .parts
-        .into_iter()
-        .filter_map(|part| match part {
-            ModelPart::Text {
-                text_kind: TextKind::Text,
-                text,
-                ..
-            } => Some(text),
-            _ => None,
-        })
-        .collect();
-    Ok(ModelGeneration {
-        text,
-        model_id,
-        finish_reason: step.finish_reason,
-        usage: step.usage,
-    })
-}
-fn failed(error: impl ToString) -> ToolError {
-    ToolError::Failed(error.to_string())
-}
diff --git a/crates/runtime-host/src/execution/plugins/llm/generate.rs b/crates/runtime-host/src/execution/plugins/llm/generate.rs
new file mode 100644
index 0000000000..5efa68245b
--- /dev/null
+++ b/crates/runtime-host/src/execution/plugins/llm/generate.rs
@@ -0,0 +1,140 @@
+/*
+ * 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,
+    usage::{AuxiliarySource, Outcome},
+};
+use maka_model::{ModelExecutor, ModelRequest, ModelStream, StepBuilder};
+use maka_runtime::{
+    model::{ModelEvent, ModelGeneration, ModelPart, ModelStep, TextKind},
+    tools::ToolError,
+};
+use std::sync::Arc;
+use tokio_util::sync::CancellationToken;
+use uuid::Uuid;
+
+pub(crate) async fn generate(
+    models: ModelExecutor,
+    request: ModelRequest,
+    adapter: maka_plugins::model::Binding,
+    cancellation: CancellationToken,
+    log: Arc<EventLog>,
+    source: AuxiliarySource,
+) -> Result<ModelGeneration, ToolError> {
+    if cancellation.is_cancelled() {
+        return Err(failed("model cancelled before dispatch"));
+    }
+    let id = log
+        .begin_auxiliary_model(source)
+        .await
+        .map_err(persistence)?;
+    let model_id = request.provider.model.clone();
+    let result = async {
+        let stream = models
+            .stream_with_adapter(request, cancellation.clone(), None, adapter)
+            .await
+            .map_err(failed)?;
+        let step = receive(stream, &log, id).await?;
+        if step.finish_reason == maka_runtime::model::ModelFinishReason::ToolCalls {
+            return Err(failed(
+                "auxiliary model generation cannot finish with tool calls",
+            ));
+        }
+        let text = step
+            .parts
+            .into_iter()
+            .filter_map(|part| match part {
+                ModelPart::Text {
+                    text_kind: TextKind::Text,
+                    text,
+                    ..
+                } => Some(text),
+                _ => None,
+            })
+            .collect();
+        Ok(ModelGeneration {
+            text,
+            model_id,
+            finish_reason: step.finish_reason,
+            usage: step.usage,
+        })
+    }
+    .await;
+    // A failed fact commit may have committed. Do not overwrite that uncertainty
+    // or let an auxiliary failure release the Host's persistence fence.
+    if matches!(result, Err(ToolError::Persistence(_))) {
+        return result;
+    }
+    let outcome = match &result {
+        Ok(_) => Outcome::Success,
+        Err(_) if cancellation.is_cancelled() => Outcome::Aborted,
+        Err(_) => Outcome::Error,
+    };
+    log.settle_auxiliary_model(id, outcome)
+        .await
+        .map_err(persistence)?;
+    result
+}
+
+async fn receive(
+    mut stream: ModelStream,
+    log: &EventLog,
+    id: Uuid,
+) -> Result<ModelStep, ToolError> {
+    let result = async {
+        let mut builder = StepBuilder::default();
+        let mut bytes = 0usize;
+        let mut observed_usage = false;
+        while let Some(event) = stream.next().await {
+            let event = event.map_err(failed)?;
+            if let ModelEvent::Finished { usage, .. } = &event
+                && !observed_usage
+            {
+                log.observe_auxiliary_model(id, usage.clone())
+                    .await
+                    .map_err(persistence)?;
+                observed_usage = true;
+            }
+            bytes = bytes.saturating_add(serde_json::to_vec(&event).map_err(failed)?.len());
+            if bytes > 2 * 1024 * 1024 {
+                return Err(failed("model generation exceeds 2 MiB stream limit"));
+            }
+            if matches!(
+                event,
+                ModelEvent::ToolCall(_) | ModelEvent::ProviderToolResult { .. }
+            ) {
+                return Err(failed("auxiliary model generation cannot call tools"));
+            }
+            builder.push(event).map_err(failed)?;
+        }
+        builder.finish().map_err(failed)
+    }
+    .await;
+    // EOF alone does not release the shared worker's permits and transport.
+    stream.cancel_and_wait().await;
+    result
+}
+
+fn persistence(error: impl ToString) -> ToolError {
+    ToolError::Persistence(error.to_string())
+}
+fn failed(error: impl ToString) -> ToolError {
+    ToolError::Failed(error.to_string())
+}
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index c7fe22d938..aaa362b90f 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -211,6 +211,7 @@ impl Host {
         )?;
         log.recover_shell_runs(recovered_at).await?;
         log.recover_host_effects().await?;
+        log.recover_auxiliary_models().await?;
         let configuration = Arc::new(ConfigurationStore::for_root(root.clone()).await?);
         let draining = CancellationToken::new();
         let startup_guard = draining.clone().drop_guard();
diff --git a/crates/runtime-host/tests/integration/javascript_plugins/metering.rs b/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
index 19619ab29e..1ca2a048a7 100644
--- a/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
+++ b/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
@@ -27,6 +27,49 @@ use maka_runtime::{
 };
 
 pub(super) async fn verify(log: &EventLog, expected: usize) {
+    use maka_event_log::usage::{Origin, Outcome, Query};
+    let usage = log
+        .model_attempts(
+            Query {
+                from: 0.0,
+                to: f64::MAX,
+                session_id: Some("js-session".into()),
+            },
+            0,
+            100,
+        )
+        .await
+        .unwrap();
+    let auxiliary: Vec<_> = usage
+        .attempts
+        .iter()
+        .filter(|attempt| matches!(attempt.origin, Origin::Auxiliary { .. }))
+        .collect();
+    assert_eq!(
+        auxiliary
+            .iter()
+            .filter(|attempt| attempt.outcome == Outcome::Success)
+            .count(),
+        expected
+    );
+    assert!(
+        auxiliary
+            .iter()
+            .any(|attempt| attempt.outcome == Outcome::Aborted),
+        "retirement must settle the abandoned model request"
+    );
+    for attempt in auxiliary {
+        if attempt.outcome == Outcome::Success {
+            assert_eq!(attempt.usage.input_tokens, Some(3));
+            assert_eq!(attempt.usage.output_tokens, Some(5));
+        } else {
+            assert_eq!(
+                attempt.usage,
+                Default::default(),
+                "cancelled stream did not report usage"
+            );
+        }
+    }
     let prefix = log.prefix(2000, 8 * 1024 * 1024).await.unwrap();
     let mut metered = Vec::new();
     for row in &prefix.events {
diff --git a/crates/runtime-host/tests/integration/plugin_authorization.rs b/crates/runtime-host/tests/integration/plugin_authorization.rs
index a0b35e9b32..bb86d71b12 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization.rs
@@ -536,6 +536,34 @@ async fn scenario() {
             .unwrap()
             .is_none()
     );
+    let usage = log
+        .model_attempts(
+            maka_event_log::usage::Query {
+                from: 0.0,
+                to: f64::MAX,
+                session_id: Some("background-session".into()),
+            },
+            0,
+            100,
+        )
+        .await
+        .unwrap();
+    assert_eq!(
+        usage.total, 3,
+        "count actual model requests, not successful tool payloads twice"
+    );
+    // This fixture uses a plugin executor, not the Agent model loop: all three
+    // requests come from authorized Remote/background SDK resource calls.
+    assert!(usage.attempts.iter().all(|attempt| matches!(
+        attempt.origin,
+        maka_event_log::usage::Origin::Auxiliary { .. }
+    )));
+    assert!(
+        usage
+            .attempts
+            .iter()
+            .all(|attempt| attempt.outcome == maka_event_log::usage::Outcome::Success)
+    );
     log.close().await.unwrap();
     assert_eq!(
         model_server.requests.lock().unwrap().len(),
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 521bdc6932..e3602edb9e 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Usage queries, revision-consistent screens/activity pages, pricing query/mutate and valuation. | **Plugin + Host.** Reports, price policy and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| Usage / Pricing | Physical Agent and auxiliary SDK call accounting and bounded source reads are implemented. Public Usage queries, revision-consistent screens/activity pages, pricing query/mutate and valuation remain. | **Plugin + Host.** Reports, price policy and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index d12204a671..c4fe08de40 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | Usage 查询、一致版本视图及活动分页,Pricing 查询/修改和估价。 | **插件 + Host。** 报表、价格策略和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 调用的物理准入计量及有界事实读取。公共 Usage 查询、一致版本视图及活动分页、Pricing 查询/修改和估价仍待接入。 | **插件 + Host。** 报表、价格策略和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 5cb04d815e..033433529e 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -212,6 +212,10 @@ user/project content paths stay separate from private journals.
   committed semantic facts. Compaction changes the model projection, not history.
   Failed response fragments remain display evidence, not accepted model history;
   user cancellation is not displayed as a provider failure.
+- Accounting counts physical model admissions, including failed retries and auxiliary
+  plugin calls. Provider usage survives rejected output; missing counters and unresolved
+  outcomes remain unknown. History copies do not duplicate usage, and Session removal
+  retains accounting facts. Usage screens and pricing are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 24d412dbc1..024d12fd68 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -166,6 +166,9 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
 - **Log Is the Runtime:**模型历史、transcript 与恢复来自已提交的语义事实。上下文压缩
   改变模型投影,不改写历史。
   失败响应的片段只用于展示,不纳入模型历史;用户取消不显示为 provider 失败。
+- 计量按模型物理准入统计,包含失败重试和插件辅助调用。被拒绝的回复仍保留已报告用量;
+  缺失计数与未确认结果保持未知。历史复制不重复计量,Session 删除保留计量事实。
+  Usage 页面与 Pricing 尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,


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


(maka) 07/09: feat(plugins): share native pricing control through public capabilities

Posted by wy...@apache.org.
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

commit bb7f39ac96b4a447c177fa54fa776d6a90f90a34
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 16:51:27 2026 +0800

    feat(plugins): share native pricing control through public capabilities
    
    Generated-by: Codex
---
 .../src/main/plugin-authorization-dialog.ts        |   1 +
 crates/js-runtime/src/plugin/sdk.js                |   6 +
 crates/plugins/src/authorization.rs                |   8 ++
 crates/plugins/src/host.rs                         |   1 +
 crates/plugins/src/lib.rs                          |   1 +
 crates/plugins/src/pricing.rs                      |  34 +++++
 crates/runtime-host/src/plugins.rs                 |   1 +
 crates/runtime-host/src/plugins/effects.rs         |   2 +-
 crates/runtime-host/src/plugins/host.rs            |   7 ++
 .../runtime-host/src/plugins/javascript/bridge.rs  |  12 ++
 .../src/plugins/javascript/bridge/wire.rs          |   4 +
 crates/runtime-host/src/plugins/pricing.rs         | 104 +++++++++++++++
 crates/runtime-host/src/server.rs                  |  10 +-
 .../src/server/plugin_authorization.rs             |   1 +
 crates/runtime-host/src/server/pricing.rs          |  61 ++++++---
 .../tests/fixtures/background-plugin.mjs           |  16 +++
 crates/runtime-host/tests/fixtures/host-plugin.mjs |  12 ++
 .../tests/integration/plugin_authorization.rs      |   5 +
 .../integration/plugin_authorization/pricing.rs    | 140 +++++++++++++++++++++
 .../integration/plugin_authorization/usage.rs      |   2 +-
 docs/rust-parity.md                                |   2 +-
 docs/rust-parity.zh-CN.md                          |   2 +-
 docs/rust-runtime.md                               |   2 +
 docs/rust-runtime.zh-CN.md                         |   1 +
 packages/plugin-sdk/README.md                      |   4 +
 packages/plugin-sdk/README.zh-CN.md                |   4 +
 packages/plugin-sdk/src/authorization.ts           |   3 +-
 packages/plugin-sdk/src/host.ts                    |   4 +
 packages/plugin-sdk/src/pricing.ts                 |  67 ++++++++++
 packages/plugin-sdk/src/usage.ts                   |  12 +-
 .../src/protocol/plugin-authorization.ts           |   6 +-
 31 files changed, 497 insertions(+), 38 deletions(-)

diff --git a/apps/desktop/src/main/plugin-authorization-dialog.ts b/apps/desktop/src/main/plugin-authorization-dialog.ts
index d65eb9bed5..a72b06f879 100644
--- a/apps/desktop/src/main/plugin-authorization-dialog.ts
+++ b/apps/desktop/src/main/plugin-authorization-dialog.ts
@@ -37,6 +37,7 @@ export function pluginAuthorizationDialog(input: PluginAuthorizationInput, local
     read_sessions: ['Read Session catalog', '读取会话目录'],
     read_history: ['Read conversation history, including archived Sessions', '读取对话历史(包括已归档会话)'],
     read_usage: ['Read model usage and estimated costs', '读取模型用量和预估费用'],
+    manage_pricing: ['Change model rates for future calls', '修改后续调用的模型报价'],
   };
   const {request} = input.command;
   const target = request.target.kind === 'profile' ? 'profile' : request.target.kind === 'session'
diff --git a/crates/js-runtime/src/plugin/sdk.js b/crates/js-runtime/src/plugin/sdk.js
index 1016dd4723..770c62de4c 100644
--- a/crates/js-runtime/src/plugin/sdk.js
+++ b/crates/js-runtime/src/plugin/sdk.js
@@ -296,7 +296,12 @@
     });
   };
   const text = (value) => (typeof value === 'function' ? value : () => value);
+  const pricing = Object.freeze({ query: (input) => host('pricing.query', input) });
   const resources = (authority) => ({
+    pricing: Object.freeze({
+      ...pricing,
+      update: (input) => host('pricing.update', { authority, input }),
+    }),
     usage: Object.freeze({ models: (input) => host('usage.models', { authority, input }) }),
     sessions: Object.freeze({ list: (input = {}) => host('sessions.list', { authority, input }) }),
     history: Object.freeze({
@@ -542,6 +547,7 @@
           resolve: (selection) => host('models.resolve', selection),
           search: (query = {}) => host('models.search', query),
         }),
+        pricing,
         async revision() {
           const handle = await host('revision', { kind: 'new' });
           let closed = false;
diff --git a/crates/plugins/src/authorization.rs b/crates/plugins/src/authorization.rs
index d952419982..2e654ae1dd 100644
--- a/crates/plugins/src/authorization.rs
+++ b/crates/plugins/src/authorization.rs
@@ -44,6 +44,7 @@ pub enum Capability {
     ReadSessions,
     ReadHistory,
     ReadUsage,
+    ManagePricing,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -101,6 +102,7 @@ impl Request {
                             | Capability::ReadSessions
                             | Capability::ReadHistory
                             | Capability::ReadUsage
+                            | Capability::ManagePricing
                     )
                 }) => {}
             Target::Profile => {
@@ -147,6 +149,12 @@ impl Request {
                 "Usage authorization requires a profile or Session target".into(),
             ));
         }
+        if self.capabilities.contains(&Capability::ManagePricing) && self.target != Target::Profile
+        {
+            return Err(crate::Error::Invalid(
+                "pricing changes require profile authorization".into(),
+            ));
+        }
         Ok(())
     }
     /// Explicit HTTP consent is independent of the file/process sandbox. Client
diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs
index 1f1c291982..4d9b1fd3db 100644
--- a/crates/plugins/src/host.rs
+++ b/crates/plugins/src/host.rs
@@ -33,6 +33,7 @@ pub struct Services {
     pub sessions: Arc<dyn crate::session::catalog::Queries>,
     pub history: Arc<dyn crate::session::history::History>,
     pub usage: Arc<dyn crate::usage::Usage>,
+    pub pricing: Arc<dyn crate::pricing::Prices>,
     pub authorizations: Arc<dyn crate::authorization::Access>,
     pub permissions: Arc<dyn crate::permissions::Access>,
     pub files: Arc<dyn crate::filesystem::Files>,
diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs
index 36c6d2ec70..184eb44d47 100644
--- a/crates/plugins/src/lib.rs
+++ b/crates/plugins/src/lib.rs
@@ -39,6 +39,7 @@ pub mod model;
 pub mod package;
 pub mod permissions;
 pub mod preferences;
+pub mod pricing;
 pub mod process;
 pub mod prompt;
 pub mod provider;
diff --git a/crates/plugins/src/pricing.rs b/crates/plugins/src/pricing.rs
new file mode 100644
index 0000000000..c4bf47caa8
--- /dev/null
+++ b/crates/plugins/src/pricing.rs
@@ -0,0 +1,34 @@
+/*
+ * 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::{call::Scope, execution::CommandError};
+use futures_util::future::BoxFuture;
+pub use maka_runtime::pricing::{
+    Entry, Mutation, Page, Pricing, Query, Quote, ResetEffect, Update, Updated,
+};
+use maka_runtime::tools::ToolError;
+
+/// Non-secret rates. Reads are available during activation; writes require an
+/// explicitly authorized profile call. Neither operation dispatches a model.
+pub trait Prices: Send + Sync {
+    fn query(&self, input: Query) -> BoxFuture<'_, Result<Page, CommandError>>;
+    /// CAS with durable revision; a lost reply may be followed by RevisionConflict.
+    /// Accepted edits finish even if the caller stops waiting.
+    fn update(&self, call: Scope, input: Update) -> BoxFuture<'_, Result<Updated, ToolError>>;
+}
diff --git a/crates/runtime-host/src/plugins.rs b/crates/runtime-host/src/plugins.rs
index 1b74b980d1..42b0357f87 100644
--- a/crates/runtime-host/src/plugins.rs
+++ b/crates/runtime-host/src/plugins.rs
@@ -28,6 +28,7 @@ mod http;
 pub(crate) mod javascript;
 pub(crate) mod models;
 mod owner;
+mod pricing;
 mod process;
 pub(crate) mod recall;
 pub(crate) mod remote;
diff --git a/crates/runtime-host/src/plugins/effects.rs b/crates/runtime-host/src/plugins/effects.rs
index 104c4f75b1..d198864b47 100644
--- a/crates/runtime-host/src/plugins/effects.rs
+++ b/crates/runtime-host/src/plugins/effects.rs
@@ -56,7 +56,7 @@ impl Effects {
                 .await
         }
     }
-    async fn owned<T: Send + 'static>(
+    pub(super) async fn owned<T: Send + 'static>(
         &self,
         authority: Authority,
         effect: impl FnOnce(
diff --git a/crates/runtime-host/src/plugins/host.rs b/crates/runtime-host/src/plugins/host.rs
index ab3329d094..720a943560 100644
--- a/crates/runtime-host/src/plugins/host.rs
+++ b/crates/runtime-host/src/plugins/host.rs
@@ -31,6 +31,7 @@ pub(crate) struct Issuer {
     inputs: maka_plugins::filesystem::ReadRoots,
     executions: Weak<Executions>,
     configuration: Arc<maka_config::ConfigurationStore>,
+    pricing: Arc<crate::server::pricing::Catalog>,
     root: String,
 }
 impl Issuer {
@@ -39,11 +40,13 @@ impl Issuer {
         configuration: Arc<maka_config::ConfigurationStore>,
         root: String,
         inputs: maka_plugins::filesystem::ReadRoots,
+        pricing: Arc<crate::server::pricing::Catalog>,
     ) -> Arc<Self> {
         Arc::new(Self {
             inputs,
             executions: Arc::downgrade(executions),
             configuration,
+            pricing,
             root,
         })
     }
@@ -83,6 +86,10 @@ impl Provider for Issuer {
                 sessions: effects.clone(),
                 history: effects.clone(),
                 usage: effects.clone(),
+                pricing: Arc::new(super::pricing::Prices::new(
+                    self.pricing.clone(),
+                    effects.clone(),
+                )),
                 http: Arc::new(super::http::Http::new(
                     self.executions.clone(),
                     owner.clone(),
diff --git a/crates/runtime-host/src/plugins/javascript/bridge.rs b/crates/runtime-host/src/plugins/javascript/bridge.rs
index e8ab6ed321..baff1d4c86 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge.rs
@@ -58,6 +58,7 @@ struct State {
     sessions: Arc<dyn maka_plugins::session::catalog::Queries>,
     history: Arc<dyn maka_plugins::session::history::History>,
     usage: Arc<dyn maka_plugins::usage::Usage>,
+    pricing: Arc<dyn maka_plugins::pricing::Prices>,
     execution_handles: Mutex<BTreeMap<String, Arc<dyn maka_plugins::execution::Commands>>>,
     authorizations: Arc<dyn maka_plugins::authorization::Access>,
     authorized_calls:
@@ -101,6 +102,7 @@ impl HostBridge {
             sessions: host.sessions,
             history: host.history,
             usage: host.usage,
+            pricing: host.pricing,
             execution_handles: Mutex::default(),
             authorizations: host.authorizations,
             authorized_calls: Mutex::default(),
@@ -307,6 +309,16 @@ impl State {
                 let call = self.calls.get(&input.authority)?;
                 encode(self.usage.models(call, input.input).await?)
             }
+            Request::PricingQuery(input) => encode(self.pricing.query(input).await?),
+            Request::PricingUpdate(input) => {
+                let call = self.calls.get(&input.authority)?;
+                encode(
+                    self.pricing
+                        .update(call, input.input)
+                        .await
+                        .map_err(Error::tool)?,
+                )
+            }
             Request::SearchExecutors(input) => encode(self.executors.search(input).await?),
             Request::Revision(input) => {
                 let _lease = self.context.lifecycle.resource_call()?;
diff --git a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
index 9c144e7d87..3d7b465d67 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
@@ -79,6 +79,10 @@ pub(super) enum Request {
     HistoryRead(HistoryRead),
     #[serde(rename = "usage.models")]
     UsageModels(Authorized<maka_plugins::usage::Read>),
+    #[serde(rename = "pricing.query")]
+    PricingQuery(maka_runtime::pricing::Query),
+    #[serde(rename = "pricing.update")]
+    PricingUpdate(Authorized<maka_runtime::pricing::Update>),
     #[serde(rename = "history.sources")]
     HistorySources(HistorySources),
     #[serde(rename = "history.copyMaterial")]
diff --git a/crates/runtime-host/src/plugins/pricing.rs b/crates/runtime-host/src/plugins/pricing.rs
new file mode 100644
index 0000000000..ed21940498
--- /dev/null
+++ b/crates/runtime-host/src/plugins/pricing.rs
@@ -0,0 +1,104 @@
+/*
+ * 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 super::effects::Effects;
+use crate::server::pricing::Catalog;
+use futures_util::future::BoxFuture;
+use maka_plugins::{
+    authorization::{Boundary, Capability},
+    call::Scope,
+    execution::CommandError,
+    pricing::{Page, Query, Update, Updated},
+};
+use maka_runtime::tools::ToolError;
+use std::sync::Arc;
+
+pub(super) struct Prices {
+    catalog: Arc<Catalog>,
+    effects: Arc<Effects>,
+}
+impl Prices {
+    pub(super) fn new(catalog: Arc<Catalog>, effects: Arc<Effects>) -> Self {
+        Self { catalog, effects }
+    }
+}
+impl maka_plugins::pricing::Prices for Prices {
+    fn query(&self, input: Query) -> BoxFuture<'_, Result<Page, CommandError>> {
+        Box::pin(async move {
+            let _lease = self
+                .effects
+                .owner
+                .resource_call()
+                .map_err(|_| CommandError::Revoked)?;
+            let host = self.effects.host.upgrade().ok_or(CommandError::Draining)?;
+            self.catalog
+                .query(input)
+                .await
+                .map_err(|error| match error {
+                    maka_config::ConfigError::Invalid(message) => CommandError::Invalid(message),
+                    maka_config::ConfigError::CommitUnknown => {
+                        host.begin_drain();
+                        CommandError::OutcomeUnknown(
+                            "pricing read could not confirm the transaction".into(),
+                        )
+                    }
+                    other => CommandError::Unavailable(other.to_string()),
+                })
+        })
+    }
+
+    fn update(&self, call: Scope, input: Update) -> BoxFuture<'_, Result<Updated, ToolError>> {
+        let catalog = self.catalog.clone();
+        Box::pin(
+            self.effects
+                .owned(call, move |host, _owner, call, _cancellation| {
+                    Box::pin(async move {
+                        // Consent revocation uses this gate too. Once admitted, an edit
+                        // and its notification finish even when the awaiting plugin retires.
+                        let _gate = host.lock_admission().await;
+                        if call.identity.agent().is_some()
+                            || !matches!(
+                                host.plugin_resource_boundary(&call, Capability::ManagePricing)
+                                    .await?,
+                                Boundary::Profile
+                            )
+                        {
+                            return Err(CommandError::Denied.into());
+                        }
+                        match catalog.update(input).await {
+                            Ok(result) => Ok(result),
+                            Err(maka_config::ConfigError::CommitUnknown) => {
+                                host.begin_drain();
+                                Err(ToolError::Persistence(
+                                    "pricing commit outcome is unknown".into(),
+                                ))
+                            }
+                            Err(maka_config::ConfigError::Invalid(message)) => {
+                                Err(ToolError::Failed(message))
+                            }
+                            Err(error) => Err(ToolError::Io {
+                                kind: std::io::ErrorKind::Other,
+                                message: error.to_string(),
+                            }),
+                        }
+                    })
+                }),
+        )
+    }
+}
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index c2c93bdebe..f428c587dd 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -43,7 +43,7 @@ mod operations;
 mod outbound;
 pub(crate) mod plugin_authorization;
 mod plugin_remote;
-mod pricing;
+pub(crate) mod pricing;
 mod projects;
 mod sandbox_setup;
 pub(crate) use projects::Usage as ProjectUsage;
@@ -132,6 +132,7 @@ pub struct Host {
     // Log connections close before root authority is released.
     log: Arc<EventLog>,
     configuration: Arc<ConfigurationStore>,
+    pricing: Arc<pricing::Catalog>,
     connection_effects: connection_effects::ConnectionEffects,
     oauth: oauth::Coordinator,
     changes: broadcast::Sender<serde_json::Value>,
@@ -221,6 +222,11 @@ impl Host {
         log.begin_message_epoch(&epoch).await?;
         let changes = broadcast::channel(64).0;
         let change_revision = Arc::new(AtomicU64::new(0));
+        let pricing = pricing::Catalog::new(
+            configuration.clone(),
+            changes.clone(),
+            change_revision.clone(),
+        );
         let project_usage =
             ProjectUsage::new(log.clone(), changes.clone(), change_revision.clone());
         let session_catalog = Arc::new(catalog_feed::CatalogFeed::new(
@@ -301,6 +307,7 @@ impl Host {
             configuration.clone(),
             root.root_id().into(),
             std::mem::take(&mut options.input_roots),
+            pricing.clone(),
         ));
         let (plugins, plugin_owner) = crate::plugins::Platform::open(
             log.clone(),
@@ -343,6 +350,7 @@ impl Host {
             project_usage,
             log,
             configuration,
+            pricing,
             connection_effects: connection_effects::ConnectionEffects::default(),
             oauth: oauth::Coordinator::default(),
             changes,
diff --git a/crates/runtime-host/src/server/plugin_authorization.rs b/crates/runtime-host/src/server/plugin_authorization.rs
index 11eea3c8a6..ec34f4ce63 100644
--- a/crates/runtime-host/src/server/plugin_authorization.rs
+++ b/crates/runtime-host/src/server/plugin_authorization.rs
@@ -293,6 +293,7 @@ fn allow(authority: &Authority, request: &Request) -> Result<(), OperationError>
             Capability::Notifications => &[],
             Capability::ReadSessions => &[Operation::SessionCatalogQuery],
             Capability::ReadUsage => &[Operation::UsageQuery],
+            Capability::ManagePricing => &[Operation::PricingMutate],
             Capability::ReadHistory => &[
                 Operation::SessionCatalogQuery,
                 Operation::SessionTranscriptPage,
diff --git a/crates/runtime-host/src/server/pricing.rs b/crates/runtime-host/src/server/pricing.rs
index 94dbcf5b2a..1a79e3edb7 100644
--- a/crates/runtime-host/src/server/pricing.rs
+++ b/crates/runtime-host/src/server/pricing.rs
@@ -18,10 +18,47 @@
  */
 
 use super::{Host, HostError, configuration};
+use maka_config::ConfigurationStore;
 use maka_protocol::{Operation, Outcome, pricing::Input};
-use maka_runtime::pricing::Updated;
+use maka_runtime::pricing::{Page, Query, Update, Updated};
 use serde_json::{Value, json};
-use std::sync::atomic::Ordering;
+use std::sync::{
+    Arc,
+    atomic::{AtomicU64, Ordering},
+};
+
+/// One mutation path for native clients and plugins, including commit notices.
+pub(crate) struct Catalog {
+    configuration: Arc<ConfigurationStore>,
+    changes: tokio::sync::broadcast::Sender<Value>,
+    revision: Arc<AtomicU64>,
+}
+impl Catalog {
+    pub(crate) fn new(
+        configuration: Arc<ConfigurationStore>,
+        changes: tokio::sync::broadcast::Sender<Value>,
+        revision: Arc<AtomicU64>,
+    ) -> Arc<Self> {
+        Arc::new(Self {
+            configuration,
+            changes,
+            revision,
+        })
+    }
+    pub(crate) async fn query(&self, input: Query) -> maka_config::Result<Page> {
+        self.configuration.query_pricing(input).await
+    }
+    pub(crate) async fn update(&self, input: Update) -> maka_config::Result<Updated> {
+        let result = self.configuration.update_pricing(input).await?;
+        if matches!(result, Updated::Committed { .. }) {
+            let revision = self.revision.fetch_add(1, Ordering::SeqCst) + 1;
+            let _ = self
+                .changes
+                .send(json!({ "kind": "configuration.changed", "revision": revision }));
+        }
+        Ok(result)
+    }
+}
 
 pub(super) async fn execute(
     host: &Host,
@@ -29,24 +66,8 @@ pub(super) async fn execute(
     value: &Value,
 ) -> Result<Outcome, HostError> {
     let output = match maka_protocol::pricing::decode_input(operation, value)? {
-        Input::Query(input) => host
-            .configuration
-            .query_pricing(input)
-            .await
-            .map(serde_json::to_value),
-        Input::Update(input) => host
-            .configuration
-            .update_pricing(input)
-            .await
-            .map(|result| {
-                if matches!(result, Updated::Committed { .. }) {
-                    let revision = host.change_revision.fetch_add(1, Ordering::SeqCst) + 1;
-                    let _ = host
-                        .changes
-                        .send(json!({ "kind": "configuration.changed", "revision": revision }));
-                }
-                serde_json::to_value(result)
-            }),
+        Input::Query(input) => host.pricing.query(input).await.map(serde_json::to_value),
+        Input::Update(input) => host.pricing.update(input).await.map(serde_json::to_value),
     };
     Ok(match output {
         Ok(value) => Outcome::success(maka_protocol::pricing::decode_output(operation, &value?)?),
diff --git a/crates/runtime-host/tests/fixtures/background-plugin.mjs b/crates/runtime-host/tests/fixtures/background-plugin.mjs
index cebef37a2e..f67b973ac1 100644
--- a/crates/runtime-host/tests/fixtures/background-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/background-plugin.mjs
@@ -19,6 +19,22 @@
 
 /** @param {import('../../../../packages/plugin-sdk/src/host.js').HostContext} ctx */
 export default async function (ctx) {
+  await ctx.remote.method('pricing', async (input) => {
+    if (!input || typeof input !== 'object' || Array.isArray(input) || !('operation' in input))
+      throw new Error('invalid pricing input');
+    if (input.operation === 'query') return ctx.pricing.query({ kind: 'start' });
+    if (!('grant' in input) || typeof input.grant !== 'string' || !('update' in input))
+      throw new Error('invalid price edit');
+    const update =
+      /** @type {import('../../../../packages/plugin-sdk/src/pricing.js').PricingUpdate} */ (
+        input.update
+      );
+    try {
+      return await ctx.withAuthorization(input.grant, (call) => call.pricing.update(update));
+    } catch (error) {
+      return { error: error.code };
+    }
+  });
   await ctx.remote.method('usage', async (input) => {
     if (
       !input ||
diff --git a/crates/runtime-host/tests/fixtures/host-plugin.mjs b/crates/runtime-host/tests/fixtures/host-plugin.mjs
index 8523228522..38fbbeb29b 100644
--- a/crates/runtime-host/tests/fixtures/host-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/host-plugin.mjs
@@ -19,6 +19,9 @@
 
 /** @param {import('../../../../packages/plugin-sdk/src/host.js').HostContext} ctx */
 export default async function (ctx) {
+  const rates = await ctx.pricing.query({ kind: 'start' });
+  if (rates.kind !== 'page' || rates.entries.length === 0)
+    throw new Error('public rate catalog is unavailable during activation');
   const choice = await ctx.models.resolve({
     kind: 'named',
     connectionSlug: 'recovery',
@@ -448,6 +451,15 @@ export default async function (ctx) {
             usage.attempts.some((row) => row.sessionId !== call.invocation.session_id)
           )
             throw new Error('Agent accounting lost usage or escaped its Session');
+          try {
+            await call.pricing.update({
+              expectedRevision: rates.revision,
+              mutation: { kind: 'delete', modelKey: '!unauthorized:price' },
+            });
+            throw new Error('Agent authority became profile price-edit consent');
+          } catch (error) {
+            if (error.code !== 'revoked') throw error;
+          }
           try {
             await call.usage.models({
               kind: 'start',
diff --git a/crates/runtime-host/tests/integration/plugin_authorization.rs b/crates/runtime-host/tests/integration/plugin_authorization.rs
index 2154478a84..e7f878848e 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization.rs
@@ -23,6 +23,7 @@ use maka_runtime_host::server::{Host, local::LocalListener};
 use serde_json::{Value, json};
 use std::time::Duration;
 use tokio_util::sync::CancellationToken;
+mod pricing;
 mod usage;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
@@ -109,6 +110,7 @@ async fn scenario() {
     let mut network_grant = Value::Null;
     let mut material_copy = Value::Null;
     let mut usage = usage::Probe::default();
+    let mut pricing = pricing::Probe::default();
     for reopened in [false, true] {
         let host = Host::open(fixture.owner()).await.unwrap();
         #[cfg(unix)]
@@ -513,6 +515,9 @@ async fn scenario() {
             accepted = result["receipt"].clone();
         }
         usage.check(&mut peer, &client, &document, reopened).await;
+        pricing
+            .check(&host, &mut peer, &client, &document, &usage.grant, reopened)
+            .await;
         peer.close().await;
         stop.cancel();
         server.await.unwrap().unwrap();
diff --git a/crates/runtime-host/tests/integration/plugin_authorization/pricing.rs b/crates/runtime-host/tests/integration/plugin_authorization/pricing.rs
new file mode 100644
index 0000000000..60bc1e1885
--- /dev/null
+++ b/crates/runtime-host/tests/integration/plugin_authorization/pricing.rs
@@ -0,0 +1,140 @@
+/*
+ * 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 super::{Host, Peer, Value, json, remote, success};
+use std::sync::Arc;
+
+#[derive(Default)]
+pub(super) struct Probe {
+    grant: Value,
+}
+impl Probe {
+    pub(super) async fn check(
+        &mut self,
+        host: &Arc<Host>,
+        peer: &mut Peer,
+        client: &Value,
+        document: &Value,
+        read_only: &Value,
+        reopened: bool,
+    ) {
+        let initial = remote(
+            peer,
+            client,
+            document,
+            "pricing",
+            json!({"operation":"query"}),
+        )
+        .await;
+        assert_eq!(initial["kind"], "page");
+        let revision = initial["revision"].as_u64().unwrap();
+        let model_key = "!acceptance:public-pricing";
+        let price = json!({"modelKey":model_key,"inputUsdPer1M":1.5,"outputUsdPer1M":2.5});
+        let upsert =
+            json!({"expectedRevision":revision,"mutation":{"kind":"upsert","pricing":price}});
+        let denied = edit(peer, client, document, read_only, upsert.clone()).await;
+        assert_eq!(
+            denied["error"], "revoked",
+            "reading usage is not price-edit consent"
+        );
+        let existing = initial["entries"]
+            .as_array()
+            .unwrap()
+            .iter()
+            .find(|row| row["pricing"]["modelKey"] == model_key);
+        if reopened {
+            assert_eq!(
+                existing.unwrap()["pricing"],
+                price,
+                "public edits survive Host restart"
+            );
+        } else {
+            assert!(existing.is_none());
+            self.grant = success(
+                peer.rpc(
+                    "plugin.authorization",
+                    json!({
+                        "client":client,"scope":"profile","command":{"kind":"approve","request":{
+                            "operationId":uuid::Uuid::new_v4(),"title":"Manage future model rates",
+                            "target":{"kind":"profile"},"capabilities":["manage_pricing"]
+                        }}
+                    }),
+                )
+                .await,
+            )["grant"]
+                .clone();
+        }
+        let mut observer = Peer::new(host.clone(), "pricing-observer").await;
+        let changed = if reopened {
+            json!({"expectedRevision":revision,"mutation":{"kind":"delete","modelKey":model_key}})
+        } else {
+            upsert
+        };
+        let committed = edit(peer, client, document, &self.grant, changed.clone()).await;
+        assert_eq!(committed, json!({"kind":"committed","revision":revision+1}));
+        // Delivery invalidates the same native configuration view, not a private plugin catalog.
+        loop {
+            let notice = observer.frame().await;
+            if notice["kind"] == "configuration.changed" {
+                break;
+            }
+        }
+        observer.close().await;
+        let replay = edit(peer, client, document, &self.grant, changed).await;
+        assert_eq!(
+            replay,
+            json!({"kind":"revision_conflict","expectedRevision":revision,"actualRevision":revision+1})
+        );
+        let current = success(peer.rpc("pricing.query", json!({"kind":"start"})).await);
+        assert_eq!(current["revision"], revision + 1);
+        let custom = current["entries"]
+            .as_array()
+            .unwrap()
+            .iter()
+            .find(|row| row["pricing"]["modelKey"] == model_key);
+        if reopened {
+            assert!(custom.is_none());
+            success(peer.rpc("plugin.authorization", json!({
+                "client":client,"scope":"profile","command":{"kind":"revoke","id":self.grant["id"]}
+            })).await);
+            let denied = edit(peer, client, document, &self.grant,
+                json!({"expectedRevision":revision+1,"mutation":{"kind":"delete","modelKey":model_key}})).await;
+            assert_eq!(denied["error"], "revoked");
+        } else {
+            assert_eq!(custom.unwrap()["source"], "custom");
+            assert_eq!(custom.unwrap()["resetEffect"], "become_unpriced");
+        }
+    }
+}
+async fn edit(
+    peer: &mut Peer,
+    client: &Value,
+    document: &Value,
+    grant: &Value,
+    update: Value,
+) -> Value {
+    remote(
+        peer,
+        client,
+        document,
+        "pricing",
+        json!({"operation":"update","grant":grant["id"],"update":update}),
+    )
+    .await
+}
diff --git a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
index c4abce6e1e..de4e906cf3 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
@@ -21,7 +21,7 @@ use super::{Peer, Value, json, remote, success};
 
 #[derive(Default)]
 pub(super) struct Probe {
-    grant: Value,
+    pub(super) grant: Value,
     cursor: Value,
 }
 impl Probe {
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index d5a3458458..6e80717459 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped model-attempt paging, and native pricing query/CAS edits are implemented. Public pricing capabilities, aggregates and Usage screens/activity pages remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped model-attempt paging, and shared native/plugin pricing query/CAS edits are implemented. Aggregates and Usage screens/activity pages remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index 021187f510..69d0d043fa 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型调用分页及原生报价查询/CAS 修改。公共报价能力、聚合统计、Usage 视图与活动分页仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型调用分页,以及原生/插件共享的报价查询与 CAS 修改。聚合统计、Usage 视图与活动分页仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index a351c38873..f3e37c7565 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -217,6 +217,8 @@ user/project content paths stay separate from private journals.
   outcomes remain unknown. History copies do not duplicate usage, and Session removal
   retains accounting facts. Pricing queries and CAS edits use a bounded, revision-pinned
   catalog; custom rates survive restart and bundled-rate updates invalidate old cursors.
+  Rust/JS plugins share this catalog and mutation path; edits require explicit profile
+  pricing consent and cannot be authorized by an Agent invocation alone.
   Each admission captures its public provider identity and rate decision; estimates commit
   with reported usage and survive rejection, edits and recovery. Public Rust/JS Usage reads
   recheck scoped authorization and return at most 100 rows / 48 KiB under a fixed settlement
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index d41fb4065e..423d438e85 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -169,6 +169,7 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
 - 计量按模型物理准入统计,包含失败重试和插件辅助调用。被拒绝的回复仍保留已报告用量;
   缺失计数与未确认结果保持未知。历史复制不重复计量,Session 删除保留计量事实。
   报价支持有界、固定版本的查询与 CAS 修改;自定义报价跨重启保留,内置报价更新使旧分页失效。
+  Rust/JS 插件复用同一报价与修改路径;改价需要 profile 范围的明确授权,Agent 调用本身不授予该权限。
   每次准入冻结公共提供商身份和报价,用量与估价一起提交;回复被拒绝、改价或恢复都不改写历史金额。
   公共 Rust/JS Usage 读取逐次校验作用域授权,在固定结算快照下每页最多返回 100 条/48 KiB;
   Host 重启使继续读取的游标失效。Usage 页面尚未接入。
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index a151067488..8bd0fd8bc0 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -147,6 +147,10 @@ Native endpoints accepting caller-supplied Host paths declare `Endpoint::requiri
 
 Each page contains at most 100 attempts / 48 KiB. `{ kind: 'continue', cursor }` re-reads that page; `nextCursor` advances under the same filter and settlement fence. Host restart invalidates cursors; every read checks current authorization.
 
+## Pricing
+
+`ctx.pricing.query({ kind: 'start' })` reads the public rate catalog during activation. Continue with the returned revision and `nextOffset`; `revision_changed` requires a new scan. `call.pricing.update({ expectedRevision, mutation })` requires explicit profile `manage_pricing` consent. Edits share the native CAS and configuration notices, affect future admissions only, and finish after admission even if the caller stops waiting. A lost reply can yield `revision_conflict` on retry; query [...]
+
 ## Model adapters
 
 `ctx.modelAdapters.register(name, open)` publishes a protocol adapter. `open('request' | 'conversation')` returns `stream(request, context)` and optional `confirm(history)`. Rust uses `maka_plugins::model::ProviderAdapter` and the same typed events, HTTP and WebSocket contracts. Model overrides select an adapter by `adapter`; defaults are `responses`, `chat-completions` and `anthropic-messages`.
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index 88aafbc7b2..7c7bfa3c7c 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -151,6 +151,10 @@ Remote 回调可以抛出携带 `RemoteFailure.code` 的 `Error`。`outcome_unkn
 
 每页最多 100 条/48 KiB。`{ kind: 'continue', cursor }` 重读该页,`nextCursor` 在相同筛选条件与结算快照下翻页。Host 重启使游标失效;每次读取都重新检查当前授权。
 
+## 报价
+
+`ctx.pricing.query({ kind: 'start' })` 在激活期间即可读取公共报价。使用返回的 revision 与 `nextOffset` 继续分页,遇到 `revision_changed` 则重新读取。`call.pricing.update({ expectedRevision, mutation })` 需要 profile 范围的 `manage_pricing` 明确授权。修改复用原生 CAS 与配置通知,只影响后续准入;已接受的修改不会因调用者停止等待而中断。丢回复后重试可能返回 `revision_conflict`,应先查询再决定下一次修改。
+
 ## 模型适配器
 
 `ctx.modelAdapters.register(name, open)` 注册协议适配器。`open('request' | 'conversation')` 返回 `stream(request, context)` 和可选的 `confirm(history)`。Rust 使用 `maka_plugins::model::ProviderAdapter`,共享类型化事件、HTTP 与 WebSocket 契约。模型 override 的 `adapter` 指定贡献名称;默认名称为 `responses`、`chat-completions`、`anthropic-messages`。
diff --git a/packages/plugin-sdk/src/authorization.ts b/packages/plugin-sdk/src/authorization.ts
index c047a5702c..b5d95f26a4 100644
--- a/packages/plugin-sdk/src/authorization.ts
+++ b/packages/plugin-sdk/src/authorization.ts
@@ -28,7 +28,8 @@ export type AuthorizationCapability =
   | 'notifications'
   | 'read_sessions'
   | 'read_history'
-  | 'read_usage';
+  | 'read_usage'
+  | 'manage_pricing';
 export type AuthorizationScope = 'profile' | `session:${string}`;
 export interface AuthorizationRequest {
   /** Retain across lost replies; changing the proposal requires a new UUID. */
diff --git a/packages/plugin-sdk/src/host.ts b/packages/plugin-sdk/src/host.ts
index 23e609d649..7b76beeb48 100644
--- a/packages/plugin-sdk/src/host.ts
+++ b/packages/plugin-sdk/src/host.ts
@@ -37,6 +37,7 @@ export type * from './providers.js';
 export type * from './clients.js';
 export type * from './history.js';
 export type * from './usage.js';
+export type * from './pricing.js';
 
 /** Independent API version, used by runtime.sdkVersion in maka.extension.json. */
 export const HOST_SDK_VERSION = 1;
@@ -137,6 +138,7 @@ export interface ResourceContext {
   /** Trusted Agent recall reads the Host profile; independent scopes use read_history. */
   readonly history: import('./history.js').History;
   readonly usage: import('./usage.js').Usage;
+  readonly pricing: import('./pricing.js').Prices;
   readonly files: Files;
   readonly llm: import('./llm.js').Llm;
   readonly clients: import('./clients.js').ClientCapabilities;
@@ -393,6 +395,8 @@ export interface HostContext {
       selection: { kind: 'default' } | { kind: 'named'; connectionSlug: string; model: string },
     ): Promise<import('./llm.js').ModelChoice | null>;
   };
+  /** Non-secret rates, readable during activation. */
+  readonly pricing: import('./pricing.js').PricingCatalog;
   /** Non-secret user preferences; no configuration or execution authority. */
   readonly preferences: {
     read(): Promise<{
diff --git a/packages/plugin-sdk/src/pricing.ts b/packages/plugin-sdk/src/pricing.ts
new file mode 100644
index 0000000000..389862f604
--- /dev/null
+++ b/packages/plugin-sdk/src/pricing.ts
@@ -0,0 +1,67 @@
+/*
+ * 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.
+ */
+
+export type Price = {
+  modelKey: string;
+  inputUsdPer1M: number;
+  outputUsdPer1M: number;
+  cacheReadUsdPer1M?: number;
+  cacheWriteUsdPer1M?: number;
+};
+export type PriceQuote = { providerId: string; revision: number; pricing: Price | null };
+
+export type PricingQuery =
+  | { kind: 'start' }
+  | { kind: 'continue'; revision: number; offset: number };
+
+export type PricingPage =
+  | {
+      kind: 'page';
+      revision: number;
+      offset: number;
+      entries: readonly (
+        | { source: 'builtin'; pricing: Price }
+        | {
+            source: 'custom';
+            pricing: Price;
+            resetEffect: 'restore_builtin' | 'become_unpriced';
+          }
+      )[];
+      nextOffset: number | null;
+    }
+  | { kind: 'revision_changed'; expectedRevision: number; actualRevision: number };
+
+export type PricingUpdate = {
+  expectedRevision: number;
+  mutation: { kind: 'upsert'; pricing: Price } | { kind: 'delete'; modelKey: string };
+};
+export type PricingUpdated =
+  | { kind: 'committed' | 'unchanged'; revision: number }
+  | { kind: 'revision_conflict'; expectedRevision: number; actualRevision: number };
+
+export interface PricingCatalog {
+  /** At most 128 entries / 48 KiB. Revision changes require restarting the scan. */
+  query(input: PricingQuery): Promise<PricingPage>;
+}
+export interface Prices extends PricingCatalog {
+  /** Requires explicit profile manage_pricing consent. Changes affect future admissions only.
+   * A lost reply can produce a revision conflict on retry; query before deciding the next edit.
+   */
+  update(input: PricingUpdate): Promise<PricingUpdated>;
+}
diff --git a/packages/plugin-sdk/src/usage.ts b/packages/plugin-sdk/src/usage.ts
index 70b6da6629..089f070283 100644
--- a/packages/plugin-sdk/src/usage.ts
+++ b/packages/plugin-sdk/src/usage.ts
@@ -37,17 +37,7 @@ export interface ModelAttempt {
   completedAt: number;
   outcome: 'success' | 'error' | 'aborted' | 'unknown';
   usage: ModelGeneration['usage'];
-  quote: {
-    providerId: string;
-    revision: number;
-    pricing: {
-      modelKey: string;
-      inputUsdPer1M: number;
-      outputUsdPer1M: number;
-      cacheReadUsdPer1M?: number;
-      cacheWriteUsdPer1M?: number;
-    } | null;
-  } | null;
+  quote: import('./pricing.js').PriceQuote | null;
   costUsd: number | null;
 }
 
diff --git a/packages/runtime-host/src/protocol/plugin-authorization.ts b/packages/runtime-host/src/protocol/plugin-authorization.ts
index 2704039d9c..14a2e28928 100644
--- a/packages/runtime-host/src/protocol/plugin-authorization.ts
+++ b/packages/runtime-host/src/protocol/plugin-authorization.ts
@@ -58,6 +58,7 @@ const capabilities: readonly AuthorizationCapability[] = [
   'read_sessions',
   'read_history',
   'read_usage',
+  'manage_pricing',
 ];
 
 export const PLUGIN_AUTHORIZATION_OPERATION_SPECS = {
@@ -193,7 +194,8 @@ function proposal(value: unknown): AuthorizationRequest {
             capability !== 'notifications' &&
             capability !== 'read_sessions' &&
             capability !== 'read_history' &&
-            capability !== 'read_usage',
+            capability !== 'read_usage' &&
+            capability !== 'manage_pricing',
         )
       )
         throw invalidProtocolFrame('Profile has no workspace authority');
@@ -237,6 +239,8 @@ function proposal(value: unknown): AuthorizationRequest {
     resolved.kind !== 'session'
   )
     throw invalidProtocolFrame('Usage authorization requires a profile or Session target');
+  if (requested.includes('manage_pricing') && resolved.kind !== 'profile')
+    throw invalidProtocolFrame('Pricing changes require profile authorization');
   return { operationId: uuid(row.operationId), title, capabilities: requested, target: resolved };
 }
 function authorizationMode(value: unknown, requested: AuthorizationRequest['capabilities']) {


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


(maka) 08/09: feat(usage): expose canonical mixed model and tool activity

Posted by wy...@apache.org.
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

commit d36945e658c19343b4ea5ce5f8826bd03fd6a20c
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 17:14:55 2026 +0800

    feat(usage): expose canonical mixed model and tool activity
    
    Generated-by: Codex
---
 crates/event-log/migrations/0001_runtime.sql       |  65 ++++-
 crates/event-log/src/usage.rs                      | 143 +++++-----
 crates/event-log/src/usage/reads.rs                | 155 +++++++++++
 crates/event-log/tests/tool_usage.rs               | 301 +++++++++++++++++++++
 crates/event-log/tests/usage_paging.rs             |  10 +-
 crates/js-runtime/src/plugin/sdk.js                |   2 +-
 crates/plugins/src/usage.rs                        |  14 +-
 crates/runtime-host/src/execution/plugins/usage.rs |   9 +-
 .../runtime-host/src/plugins/javascript/bridge.rs  |   4 +-
 .../src/plugins/javascript/bridge/wire.rs          |   4 +-
 crates/runtime-host/src/plugins/usage.rs           |   4 +-
 .../tests/fixtures/background-plugin.mjs           |   2 +-
 crates/runtime-host/tests/fixtures/host-plugin.mjs |  18 +-
 .../integration/plugin_authorization/usage.rs      |  48 +++-
 crates/runtime/src/accounting.rs                   |   6 +-
 crates/runtime/src/accounting/activity.rs          |  64 +++++
 .../src/{accounting.rs => accounting/tool.rs}      |  71 ++---
 crates/runtime/src/tool_call.rs                    |  12 +
 docs/rust-parity.md                                |   4 +-
 docs/rust-parity.zh-CN.md                          |   4 +-
 docs/rust-runtime.md                               |   3 +-
 docs/rust-runtime.zh-CN.md                         |   2 +-
 packages/plugin-sdk/README.md                      |   4 +-
 packages/plugin-sdk/README.zh-CN.md                |   4 +-
 packages/plugin-sdk/src/usage.ts                   |  60 +++-
 25 files changed, 848 insertions(+), 165 deletions(-)

diff --git a/crates/event-log/migrations/0001_runtime.sql b/crates/event-log/migrations/0001_runtime.sql
index 2f40d64bfa..59f0e88898 100644
--- a/crates/event-log/migrations/0001_runtime.sql
+++ b/crates/event-log/migrations/0001_runtime.sql
@@ -29,7 +29,8 @@ CREATE TABLE event_log (
     -- pages. SQLite derives them from canonical bytes; they cannot be edited.
     accounting_at REAL GENERATED ALWAYS AS (
         CASE WHEN kind IN ('model_requested', 'model_completed', 'model_interrupted',
-                           'invocation_ended', 'auxiliary_model_started', 'auxiliary_model_settled')
+                           '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
@@ -49,6 +50,17 @@ CREATE TABLE event_log (
     invocation_model TEXT GENERATED ALWAYS AS (
         CASE WHEN kind = 'invocation_opened' THEN json_extract(event_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'))
+            WHEN kind = 'tool_settled' THEN json_object(
+                'outcome', json_extract(event_json, '$.fact.outcome.kind'))
+        END
+    ) STORED,
     event_json TEXT NOT NULL,
     CHECK(invocation_id IS NOT NULL OR operation_id IS NULL)
 );
@@ -411,6 +423,57 @@ FROM (SELECT * FROM agent_model_usage UNION ALL SELECT * FROM auxiliary_model_us
 LEFT JOIN model_accounting accounting ON accounting.request_id = calls.event_id
 LEFT JOIN model_valuations valuation ON valuation.request_id = calls.event_id;
 
+CREATE INDEX tool_usage_requests ON event_log(sequence)
+    WHERE kind IN ('tool_dispatched', 'tool_rejected');
+
+-- Refusal is a known non-dispatch, not an executed tool with fabricated duration.
+CREATE VIEW tool_usage AS
+SELECT request.sequence, request.event_id,
+    json_extract(request.accounting_tool, '$.invocation') AS invocation,
+    json_extract(request.accounting_tool, '$.invocation.session_id') AS session_id,
+    json_extract(request.accounting_tool, '$.call') AS call,
+    json_extract(request.accounting_tool, '$.name') AS name,
+    opening.invocation_model AS binding,
+    CASE WHEN request.kind = 'tool_rejected' THEN request.accounting_at
+        ELSE COALESCE(settled.accounting_at, terminal.accounting_at) END AS completed_at,
+    CASE WHEN request.kind = 'tool_rejected' THEN request.sequence
+        ELSE COALESCE(settled.sequence, terminal.sequence) END AS completed_sequence,
+    CASE WHEN request.kind = 'tool_rejected' THEN json_object(
+        'kind', 'rejected', 'reason', json_extract(request.accounting_tool, '$.rejection'))
+    ELSE json_object('kind', 'settled', 'startedAt', request.accounting_at,
+        '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
+    AND opening.kind = 'invocation_opened'
+LEFT JOIN runtime_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
+    AND terminal.kind = 'invocation_ended'
+WHERE request.kind IN ('tool_dispatched', 'tool_rejected');
+
+-- Common ordering and filtering across kinds; bodies never enter this projection.
+CREATE VIEW usage_activity AS
+SELECT 'model' AS kind, sequence, event_id, session_id, completed_sequence,
+    binding, completed_at, origin, model_id, started_at, outcome, usage, quote_json, usd,
+    NULL AS invocation, NULL AS call, NULL AS name, NULL AS result,
+    outcome AS status,
+    COALESCE(session_id, '') || ' ' || model_id || ' ' ||
+        COALESCE(json_extract(binding, '$.connection_slug'), '') || ' ' ||
+        COALESCE(json_extract(quote_json, '$.providerId'), '') AS search
+FROM model_usage
+UNION ALL
+SELECT 'tool', sequence, event_id, session_id, completed_sequence,
+    binding, completed_at, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+    invocation, call, name, result,
+    CASE WHEN json_extract(result, '$.kind') = 'rejected' THEN 'rejected'
+        ELSE json_extract(result, '$.outcome') END,
+    session_id || ' ' || name || ' ' ||
+        COALESCE(json_extract(binding, '$.connection_slug'), '') || ' ' ||
+        COALESCE(json_extract(binding, '$.model'), '')
+FROM tool_usage;
+
 CREATE UNIQUE INDEX message_steering_identity ON event_log(
     json_extract(event_json, '$.invocation.session_id'),
     json_extract(event_json, '$.fact.message.message_id')
diff --git a/crates/event-log/src/usage.rs b/crates/event-log/src/usage.rs
index 477649764e..133edca9c7 100644
--- a/crates/event-log/src/usage.rs
+++ b/crates/event-log/src/usage.rs
@@ -21,10 +21,13 @@
 //! Missing counters remain unknown; this layer makes no pricing assumptions.
 
 use crate::{EventLog, StoreError};
-pub use maka_runtime::accounting::{AuxiliarySource, ModelAttempt, Origin, Outcome};
-use sqlx::{Connection, Row, sqlite::SqliteRow};
+pub use maka_runtime::accounting::{
+    Activity, AuxiliarySource, ModelAttempt, Origin, Outcome, Selection, ToolAttempt,
+};
+use sqlx::{Row, sqlite::SqliteRow};
 
 mod auxiliary;
+mod reads;
 pub(crate) mod valuation;
 
 #[derive(Clone, Debug)]
@@ -50,96 +53,51 @@ impl Query {
     }
 }
 
-pub struct ModelPage {
+pub struct Page<T> {
     pub through: u64,
-    pub attempts: Vec<ModelAttempt>,
+    pub attempts: Vec<T>,
     pub total: u64,
     pub next_offset: Option<u64>,
 }
 
 impl EventLog {
-    /// Bounded, settled model admissions (Agent inference, compaction and Host SDK).
-    /// Includes failed physical retries and provider usage from rejected output.
-    /// Session removal retains these canonical facts; copies do not duplicate them.
+    pub async fn usage_activity(
+        &self,
+        query: Query,
+        selection: Selection,
+        offset: u64,
+        limit: u32,
+    ) -> Result<Page<Activity>, StoreError> {
+        selection.validate().map_err(invalid)?;
+        reads::page(
+            self,
+            query,
+            offset,
+            limit,
+            reads::View::Activity(selection),
+            read_activity,
+        )
+        .await
+    }
+
+    /// Settled physical admissions, not copied conversation membership.
     pub async fn model_attempts(
         &self,
         query: Query,
         offset: u64,
         limit: u32,
-    ) -> Result<ModelPage, StoreError> {
-        self.validate_root()?;
-        query.validate()?;
-        if offset > i64::MAX as u64 || !(1..=100).contains(&limit) {
-            return Err(invalid("invalid Usage page"));
-        }
-        self.connection
-            .run(move |connection| {
-                Box::pin(async move {
-                    let mut tx = connection.begin().await?;
-                    let current = crate::sequence_number(
-                        sqlx::query_scalar("SELECT COALESCE(MAX(sequence), 0) FROM event_log")
-                            .fetch_one(&mut *tx)
-                            .await?,
-                    )?;
-                    let through = query.through.unwrap_or(current);
-                    if through > current {
-                        return Err(invalid("Usage fence is in the future"));
-                    }
-                    let total: i64 = sqlx::query_scalar(
-                        "SELECT COUNT(*) FROM model_usage
-                 WHERE completed_at >= ?1 AND completed_at <= ?2
-                 AND (?3 IS NULL OR session_id = ?3) AND completed_sequence <= ?4",
-                    )
-                    .bind(query.from)
-                    .bind(query.to)
-                    .bind(&query.session_id)
-                    .bind(through as i64)
-                    .fetch_one(&mut *tx)
-                    .await?;
-                    let rows = sqlx::query(
-                        "SELECT event_id, origin, session_id, binding, model_id,
-                        started_at, completed_at, outcome, usage, quote_json, usd
-                 FROM model_usage
-                 WHERE completed_at >= ?1 AND completed_at <= ?2
-                 AND (?3 IS NULL OR session_id = ?3)
-                 AND completed_sequence <= ?4
-                 ORDER BY completed_at DESC, sequence DESC LIMIT ?5 OFFSET ?6",
-                    )
-                    .bind(query.from)
-                    .bind(query.to)
-                    .bind(query.session_id)
-                    .bind(through as i64)
-                    .bind(limit)
-                    .bind(offset as i64)
-                    .fetch_all(&mut *tx)
-                    .await?;
-                    let mut attempts = Vec::with_capacity(rows.len());
-                    // Leave envelope/cursor headroom. Never truncate one accounting fact.
-                    let mut bytes = 0;
-                    for row in &rows {
-                        let attempt = read(row)?;
-                        let size = serde_json::to_vec(&attempt)?.len() + 1;
-                        if bytes + size > 44 * 1024 {
-                            if attempts.is_empty() {
-                                return Err(invalid("one Usage record exceeds page capacity"));
-                            }
-                            break;
-                        }
-                        bytes += size;
-                        attempts.push(attempt);
-                    }
-                    let total = crate::sequence_number(total)?;
-                    let end = offset + attempts.len() as u64;
-                    tx.commit().await?;
-                    Ok(ModelPage {
-                        through,
-                        attempts,
-                        total,
-                        next_offset: (end < total).then_some(end),
-                    })
-                })
-            })
-            .await
+    ) -> Result<Page<ModelAttempt>, StoreError> {
+        reads::page(self, query, offset, limit, reads::View::Models, read).await
+    }
+
+    /// Dispatched tools and known refusals, without inputs or result bodies.
+    pub async fn tool_attempts(
+        &self,
+        query: Query,
+        offset: u64,
+        limit: u32,
+    ) -> Result<Page<ToolAttempt>, StoreError> {
+        reads::page(self, query, offset, limit, reads::View::Tools, read_tool).await
     }
 }
 
@@ -169,3 +127,26 @@ fn read(row: &SqliteRow) -> Result<ModelAttempt, StoreError> {
 fn invalid(message: &str) -> StoreError {
     StoreError::InvalidTransition(message.into())
 }
+
+fn read_tool(row: &SqliteRow) -> Result<ToolAttempt, StoreError> {
+    Ok(ToolAttempt {
+        request_id: row.try_get("event_id")?,
+        invocation: serde_json::from_str(row.try_get("invocation")?)?,
+        call: serde_json::from_str(row.try_get("call")?)?,
+        name: row.try_get("name")?,
+        binding: row
+            .try_get::<Option<&str>, _>("binding")?
+            .map(serde_json::from_str)
+            .transpose()?,
+        completed_at: row.try_get("completed_at")?,
+        result: serde_json::from_str(row.try_get("result")?)?,
+    })
+}
+
+fn read_activity(row: &SqliteRow) -> Result<Activity, StoreError> {
+    match row.try_get::<&str, _>("kind")? {
+        "model" => Ok(Activity::Model(read(row)?)),
+        "tool" => Ok(Activity::Tool(read_tool(row)?)),
+        _ => Err(invalid("invalid accounting activity kind")),
+    }
+}
diff --git a/crates/event-log/src/usage/reads.rs b/crates/event-log/src/usage/reads.rs
new file mode 100644
index 0000000000..b0a4c44a14
--- /dev/null
+++ b/crates/event-log/src/usage/reads.rs
@@ -0,0 +1,155 @@
+/*
+ * 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 super::{EventLog, Page, Query, StoreError, invalid};
+use maka_runtime::accounting::{ActivityKind, ActivityStatus, Selection};
+use serde::Serialize;
+use sqlx::{Connection, QueryBuilder, sqlite::SqliteRow};
+
+pub(super) enum View {
+    Models,
+    Tools,
+    Activity(Selection),
+}
+impl View {
+    fn table(&self) -> &'static str {
+        match self {
+            Self::Models => "model_usage",
+            Self::Tools => "tool_usage",
+            Self::Activity(_) => "usage_activity",
+        }
+    }
+    fn columns(&self) -> &'static str {
+        match self {
+            Self::Models => {
+                "event_id, origin, session_id, binding, model_id, started_at, completed_at, outcome, usage, quote_json, usd"
+            }
+            Self::Tools => "event_id, invocation, call, name, binding, completed_at, result",
+            Self::Activity(_) => {
+                "kind, event_id, origin, session_id, binding, model_id, started_at, completed_at, outcome, usage, quote_json, usd, invocation, call, name, result"
+            }
+        }
+    }
+
+    fn restrict(&self, sql: &mut QueryBuilder<sqlx::Sqlite>, query: &Query, through: u64) {
+        sql.push(" WHERE completed_at >= ")
+            .push_bind(query.from)
+            .push(" AND completed_at <= ")
+            .push_bind(query.to)
+            .push(" AND completed_sequence <= ")
+            .push_bind(through as i64);
+        if let Some(session) = &query.session_id {
+            sql.push(" AND session_id = ").push_bind(session.clone());
+        }
+        if let Self::Activity(selection) = self {
+            if let Some(kind) = selection.kind {
+                sql.push(" AND kind = ").push_bind(match kind {
+                    ActivityKind::Model => "model",
+                    ActivityKind::Tool => "tool",
+                });
+            }
+            if let Some(status) = selection.status {
+                sql.push(" AND status = ").push_bind(match status {
+                    ActivityStatus::Success => "success",
+                    ActivityStatus::Error => "error",
+                    ActivityStatus::Aborted => "aborted",
+                    ActivityStatus::Unknown => "unknown",
+                    ActivityStatus::Rejected => "rejected",
+                });
+            }
+            if !selection.search.is_empty() {
+                sql.push(" AND instr(lower(search), lower(")
+                    .push_bind(selection.search.clone())
+                    .push(")) > 0");
+            }
+        }
+    }
+}
+
+pub(super) async fn page<T: Serialize + Send + 'static>(
+    log: &EventLog,
+    query: Query,
+    offset: u64,
+    limit: u32,
+    view: View,
+    read: fn(&SqliteRow) -> Result<T, StoreError>,
+) -> Result<Page<T>, StoreError> {
+    log.validate_root()?;
+    query.validate()?;
+    if offset > i64::MAX as u64 || !(1..=100).contains(&limit) {
+        return Err(invalid("invalid Usage page"));
+    }
+    log.connection
+        .run(move |connection| {
+            Box::pin(async move {
+                let mut tx = connection.begin().await?;
+                let current = crate::sequence_number(
+                    sqlx::query_scalar("SELECT COALESCE(MAX(sequence), 0) FROM event_log")
+                        .fetch_one(&mut *tx)
+                        .await?,
+                )?;
+                let through = query.through.unwrap_or(current);
+                if through > current {
+                    return Err(invalid("Usage fence is in the future"));
+                }
+                // Only closed internal view/column identifiers are interpolated.
+                let mut count = QueryBuilder::new("SELECT COUNT(*) FROM ");
+                count.push(view.table());
+                view.restrict(&mut count, &query, through);
+                let total: i64 = count.build_query_scalar().fetch_one(&mut *tx).await?;
+                let mut select = QueryBuilder::new("SELECT ");
+                select
+                    .push(view.columns())
+                    .push(" FROM ")
+                    .push(view.table());
+                view.restrict(&mut select, &query, through);
+                select
+                    .push(" ORDER BY completed_at DESC, sequence DESC LIMIT ")
+                    .push_bind(limit)
+                    .push(" OFFSET ")
+                    .push_bind(offset as i64);
+                let rows = select.build().fetch_all(&mut *tx).await?;
+                let mut attempts = Vec::with_capacity(rows.len());
+                // Leave envelope/cursor headroom. Never truncate one accounting fact.
+                let mut bytes = 0;
+                for row in &rows {
+                    let attempt = read(row)?;
+                    let size = serde_json::to_vec(&attempt)?.len() + 1;
+                    if bytes + size > 32 * 1024 {
+                        if attempts.is_empty() {
+                            return Err(invalid("one Usage record exceeds page capacity"));
+                        }
+                        break;
+                    }
+                    bytes += size;
+                    attempts.push(attempt);
+                }
+                let total = crate::sequence_number(total)?;
+                let end = offset + attempts.len() as u64;
+                tx.commit().await?;
+                Ok(Page {
+                    through,
+                    attempts,
+                    total,
+                    next_offset: (end < total).then_some(end),
+                })
+            })
+        })
+        .await
+}
diff --git a/crates/event-log/tests/tool_usage.rs b/crates/event-log/tests/tool_usage.rs
new file mode 100644
index 0000000000..9ee7f32270
--- /dev/null
+++ b/crates/event-log/tests/tool_usage.rs
@@ -0,0 +1,301 @@
+/*
+ * 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;
+use maka_runtime::{
+    accounting::{Activity, ActivityKind, ActivityStatus, Selection, ToolResult, ToolStatus},
+    event::{EventWrite, Fact, InvocationInput, InvocationOutcome, ToolOutcome},
+    tool_call::{RejectionKind, ToolCallIdentity, ToolRejection},
+};
+use serde_json::json;
+use std::time::{Duration, UNIX_EPOCH};
+
+#[path = "usage/fixture.rs"]
+mod fixture;
+use fixture::{event, query, request};
+
+#[tokio::test]
+async fn tool_accounting_preserves_dispatch_truth_and_snapshot_without_private_bodies() {
+    let directory = tempfile::tempdir().unwrap();
+    let path = directory.path().join("usage.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    log.append(&event(
+        Fact::InvocationOpened {
+            configuration: None,
+            input: InvocationInput::Message {
+                content: "question".into(),
+                source_messages: vec![],
+                request_fingerprint: None,
+            },
+        },
+        0,
+    ))
+    .await
+    .unwrap();
+    let private = "private-tool-data".repeat(4096);
+    let refusal = event(
+        Fact::ToolRejected {
+            operation_id: "refusal".into(),
+            call: ToolCallIdentity::standalone("refusal".into()),
+            name: "Shell".into(),
+            input: json!({"command": private}),
+            reason: ToolRejection::PolicyDenied {
+                message: private.clone(),
+            },
+        },
+        1250,
+    );
+    log.append(&refusal).await.unwrap();
+    log.append(&event(
+        Fact::ToolRejected {
+            operation_id: "cancelled".into(),
+            call: ToolCallIdentity::standalone("cancelled".into()),
+            name: "Shell".into(),
+            input: json!({}),
+            reason: ToolRejection::Cancelled,
+        },
+        1500,
+    ))
+    .await
+    .unwrap();
+    for (i, id) in ["success", "failed", "unknown", "lost"]
+        .into_iter()
+        .enumerate()
+    {
+        log.append(&event(
+            Fact::ToolDispatched {
+                operation_id: id.into(),
+                call: ToolCallIdentity::standalone(id.into()),
+                name: "Shell".into(),
+                input: json!({"command": private}),
+            },
+            2000 + i as u64 * 1000,
+        ))
+        .await
+        .unwrap();
+    }
+    let captured = log.tool_attempts(query(), 0, 100).await.unwrap();
+    assert_eq!(
+        captured.total, 2,
+        "pending dispatches have no completed outcome"
+    );
+    let success = EventWrite::tool_success(
+        "success-settled".into(),
+        UNIX_EPOCH + Duration::from_micros(6250),
+        refusal.event().invocation.clone(),
+        "success".into(),
+        json!({"output": private}).into(),
+    )
+    .unwrap()
+    .0;
+    log.append(&success).await.unwrap();
+    for (id, outcome) in [
+        (
+            "failed",
+            ToolOutcome::Failed {
+                message: private.clone(),
+            },
+        ),
+        (
+            "unknown",
+            ToolOutcome::Unknown {
+                message: private.clone(),
+            },
+        ),
+    ] {
+        log.append(&event(
+            Fact::ToolSettled {
+                operation_id: id.into(),
+                outcome,
+            },
+            6500,
+        ))
+        .await
+        .unwrap();
+    }
+    log.append_batch(&[
+        request("model-step", 6000),
+        event(
+            Fact::ModelInterrupted {
+                step_id: "model-step".into(),
+                status: maka_runtime::event::ModelInterruption::RetryableFailure,
+            },
+            6500,
+        ),
+    ])
+    .await
+    .unwrap();
+    // A cancellation of the enclosing invocation cannot relabel a failed tool.
+    log.append(&event(
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::Cancelled {
+                source: "user".into(),
+            },
+        },
+        7000,
+    ))
+    .await
+    .unwrap();
+    let mut fixed = query();
+    fixed.through = Some(captured.through);
+    assert_eq!(
+        log.tool_attempts(fixed.clone(), 0, 100)
+            .await
+            .unwrap()
+            .attempts,
+        captured.attempts
+    );
+    let page = log.tool_attempts(query(), 0, 100).await.unwrap();
+    assert_eq!(page.total, 6);
+    let find = |id: &str| {
+        page.attempts
+            .iter()
+            .find(|item| item.call.tool_call_id == id)
+            .unwrap()
+    };
+    assert_eq!(
+        find("refusal").result,
+        ToolResult::Rejected {
+            reason: RejectionKind::PolicyDenied
+        }
+    );
+    assert_eq!(find("refusal").completed_at, 1.25);
+    assert_eq!(find("refusal").latency_ms(), None);
+    assert_eq!(
+        find("cancelled").result,
+        ToolResult::Rejected {
+            reason: RejectionKind::Cancelled
+        }
+    );
+    for (id, status) in [
+        ("success", ToolStatus::Success),
+        ("failed", ToolStatus::Error),
+        ("unknown", ToolStatus::Unknown),
+        ("lost", ToolStatus::Unknown),
+    ] {
+        assert!(
+            matches!(find(id).result, ToolResult::Settled { outcome, .. } if outcome == status)
+        );
+    }
+    assert_eq!(find("success").latency_ms(), Some(4.25));
+    let bytes = serde_json::to_string(&page.attempts).unwrap();
+    assert!(!bytes.contains("private-tool-data"));
+    assert!(bytes.len() < 4096, "large bodies are not accounting fields");
+    log.append(&refusal).await.unwrap();
+    log.append(&success).await.unwrap();
+    let all = log
+        .usage_activity(query(), Selection::default(), 0, 100)
+        .await
+        .unwrap();
+    assert_eq!(all.total, 7);
+    // The model and two tool outcomes tie on completion time; admission sequence
+    // provides one deterministic order across both kinds.
+    assert!(
+        matches!(&all.attempts[0], Activity::Tool(attempt) if attempt.call.tool_call_id == "lost")
+    );
+    assert!(matches!(&all.attempts[1], Activity::Model(attempt) if attempt.model_id == "model"));
+    let mut second_query = query();
+    second_query.through = Some(all.through);
+    let first = log
+        .usage_activity(second_query.clone(), Selection::default(), 0, 2)
+        .await
+        .unwrap();
+    let rest = log
+        .usage_activity(
+            second_query,
+            Selection::default(),
+            first.next_offset.unwrap(),
+            100,
+        )
+        .await
+        .unwrap();
+    assert_eq!([first.attempts, rest.attempts].concat(), all.attempts);
+    let errors = log
+        .usage_activity(
+            query(),
+            Selection {
+                kind: Some(ActivityKind::Tool),
+                status: Some(ActivityStatus::Error),
+                search: "sHeLl".into(),
+            },
+            0,
+            100,
+        )
+        .await
+        .unwrap();
+    assert_eq!(errors.total, 1);
+    assert_eq!(
+        errors.attempts,
+        vec![Activity::Tool(find("failed").clone())]
+    );
+    let literal = log
+        .usage_activity(
+            query(),
+            Selection {
+                search: "%' OR 1=1 --".into(),
+                ..Default::default()
+            },
+            0,
+            100,
+        )
+        .await
+        .unwrap();
+    assert_eq!(
+        literal.total, 0,
+        "search is a bound literal, not SQL or LIKE syntax"
+    );
+    assert!(
+        log.usage_activity(
+            query(),
+            Selection {
+                search: "字".repeat(342),
+                ..Default::default()
+            },
+            0,
+            100
+        )
+        .await
+        .is_err(),
+        "search limit counts UTF-8 bytes"
+    );
+    let old = log
+        .usage_activity(fixed.clone(), Selection::default(), 0, 100)
+        .await
+        .unwrap();
+    assert_eq!(
+        old.attempts,
+        captured
+            .attempts
+            .iter()
+            .cloned()
+            .map(Activity::Tool)
+            .collect::<Vec<_>>()
+    );
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    assert_eq!(
+        log.tool_attempts(query(), 0, 100).await.unwrap().attempts,
+        page.attempts
+    );
+    assert_eq!(
+        log.tool_attempts(fixed, 0, 100).await.unwrap().attempts,
+        captured.attempts
+    );
+    log.close().await.unwrap();
+}
diff --git a/crates/event-log/tests/usage_paging.rs b/crates/event-log/tests/usage_paging.rs
index 72e9c2b16d..4674bb5f0d 100644
--- a/crates/event-log/tests/usage_paging.rs
+++ b/crates/event-log/tests/usage_paging.rs
@@ -19,6 +19,7 @@
 
 use maka_event_log::{EventLog, usage::Query};
 use maka_runtime::{
+    accounting::{Activity, Selection},
     context::ModelPurpose,
     event::{
         EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, LogScope, RuntimeEvent,
@@ -100,13 +101,16 @@ async fn accounting_pages_bound_bytes_without_dropping_records_or_reading_respon
     let mut seen = BTreeSet::new();
     loop {
         let page = log
-            .model_attempts(query.clone(), offset, 100)
+            .usage_activity(query.clone(), Selection::default(), offset, 100)
             .await
             .unwrap();
         assert_eq!(page.total, 80);
         assert!(!page.attempts.is_empty() && page.attempts.len() < 80);
-        assert!(serde_json::to_vec(&page.attempts).unwrap().len() <= 44 * 1024);
-        for attempt in page.attempts {
+        assert!(serde_json::to_vec(&page.attempts).unwrap().len() <= 32 * 1024);
+        for activity in page.attempts {
+            let Activity::Model(attempt) = activity else {
+                panic!("unexpected tool activity")
+            };
             assert!(seen.insert(attempt.request_id));
             assert_eq!(attempt.session_id.as_deref(), Some("usage"));
         }
diff --git a/crates/js-runtime/src/plugin/sdk.js b/crates/js-runtime/src/plugin/sdk.js
index 770c62de4c..a2eb622d44 100644
--- a/crates/js-runtime/src/plugin/sdk.js
+++ b/crates/js-runtime/src/plugin/sdk.js
@@ -302,7 +302,7 @@
       ...pricing,
       update: (input) => host('pricing.update', { authority, input }),
     }),
-    usage: Object.freeze({ models: (input) => host('usage.models', { authority, input }) }),
+    usage: Object.freeze({ activity: (input) => host('usage.activity', { authority, input }) }),
     sessions: Object.freeze({ list: (input = {}) => host('sessions.list', { authority, input }) }),
     history: Object.freeze({
       list: (input = {}) => host('history.list', { authority, input }),
diff --git a/crates/plugins/src/usage.rs b/crates/plugins/src/usage.rs
index 51e63961d5..10bc27c982 100644
--- a/crates/plugins/src/usage.rs
+++ b/crates/plugins/src/usage.rs
@@ -21,7 +21,10 @@ use crate::{call::Scope, execution::CommandError};
 use futures_util::future::BoxFuture;
 use serde::{Deserialize, Serialize};
 
-pub use maka_runtime::accounting::{AuxiliarySource, ModelAttempt, Origin, Outcome};
+pub use maka_runtime::accounting::{
+    Activity, ActivityKind, ActivityStatus, AuxiliarySource, ModelAttempt, Origin, Outcome,
+    Selection, ToolAttempt, ToolResult, ToolStatus,
+};
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -30,9 +33,14 @@ pub struct Filter {
     pub from: f64,
     pub to: f64,
     pub session_id: Option<String>,
+    #[serde(default)]
+    pub activity: Selection,
 }
 impl Filter {
     pub fn validate(&self) -> Result<(), CommandError> {
+        self.activity
+            .validate()
+            .map_err(|message| CommandError::Invalid(message.into()))?;
         if !self.from.is_finite() || !self.to.is_finite() || self.from < 0.0 || self.to < self.from
         {
             return Err(CommandError::Invalid("invalid Usage time range".into()));
@@ -62,7 +70,7 @@ pub struct Page {
     /// Re-read this exact page under its original filter and settlement fence.
     pub cursor: String,
     pub next_cursor: Option<String>,
-    pub attempts: Vec<ModelAttempt>,
+    pub attempts: Vec<Activity>,
     pub total: u64,
 }
 
@@ -70,5 +78,5 @@ pub struct Page {
 /// Agent calls see their Session; independent calls require read_usage consent.
 pub trait Usage: Send + Sync {
     /// At most 100 rows / 48 KiB. Every continuation rechecks current authority.
-    fn models(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>>;
+    fn activity(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>>;
 }
diff --git a/crates/runtime-host/src/execution/plugins/usage.rs b/crates/runtime-host/src/execution/plugins/usage.rs
index 41d14529bc..5030ab0ce1 100644
--- a/crates/runtime-host/src/execution/plugins/usage.rs
+++ b/crates/runtime-host/src/execution/plugins/usage.rs
@@ -40,7 +40,7 @@ struct Cursor {
 }
 impl Cursor {
     fn decode(value: &str, generation: &str) -> Result<Self, Error> {
-        if value.len() > 1024 {
+        if value.len() > 8192 {
             return Err(Error::Invalid("oversized Usage cursor".into()));
         }
         let cursor: Self = serde_json::from_slice(
@@ -61,7 +61,7 @@ impl Cursor {
     fn encode(&self) -> Result<String, Error> {
         let bytes = serde_json::to_vec(self).map_err(|_| invalid_cursor())?;
         let encoded = URL_SAFE_NO_PAD.encode(bytes);
-        if encoded.len() > 1024 {
+        if encoded.len() > 8192 {
             return Err(invalid_cursor());
         }
         Ok(encoded)
@@ -81,7 +81,7 @@ impl Executions {
         }
     }
 
-    pub(crate) async fn plugin_usage_models(
+    pub(crate) async fn plugin_usage_activity(
         &self,
         call: Scope,
         input: Read,
@@ -101,13 +101,14 @@ impl Executions {
         }
         let result = self
             .log
-            .model_attempts(
+            .usage_activity(
                 maka_event_log::usage::Query {
                     from: filter.from,
                     to: filter.to,
                     session_id: filter.session_id.clone(),
                     through,
                 },
+                filter.activity.clone(),
                 offset,
                 100,
             )
diff --git a/crates/runtime-host/src/plugins/javascript/bridge.rs b/crates/runtime-host/src/plugins/javascript/bridge.rs
index baff1d4c86..a950dfbb06 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge.rs
@@ -305,9 +305,9 @@ impl State {
                 encode(self.history.copy_session(call, target, input.input).await?)
             }
             Request::SearchModels(input) => encode(self.models.search(input).await?),
-            Request::UsageModels(input) => {
+            Request::UsageActivity(input) => {
                 let call = self.calls.get(&input.authority)?;
-                encode(self.usage.models(call, input.input).await?)
+                encode(self.usage.activity(call, input.input).await?)
             }
             Request::PricingQuery(input) => encode(self.pricing.query(input).await?),
             Request::PricingUpdate(input) => {
diff --git a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
index 3d7b465d67..1e407a5b99 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
@@ -77,8 +77,8 @@ pub(super) enum Request {
     HistoryList(SessionList),
     #[serde(rename = "history.read")]
     HistoryRead(HistoryRead),
-    #[serde(rename = "usage.models")]
-    UsageModels(Authorized<maka_plugins::usage::Read>),
+    #[serde(rename = "usage.activity")]
+    UsageActivity(Authorized<maka_plugins::usage::Read>),
     #[serde(rename = "pricing.query")]
     PricingQuery(maka_runtime::pricing::Query),
     #[serde(rename = "pricing.update")]
diff --git a/crates/runtime-host/src/plugins/usage.rs b/crates/runtime-host/src/plugins/usage.rs
index d032804e18..9b3505c5af 100644
--- a/crates/runtime-host/src/plugins/usage.rs
+++ b/crates/runtime-host/src/plugins/usage.rs
@@ -26,11 +26,11 @@ use maka_plugins::{
 };
 
 impl Usage for Effects {
-    fn models(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>> {
+    fn activity(&self, call: Scope, input: Read) -> BoxFuture<'_, Result<Page, CommandError>> {
         Box::pin(async move {
             let _lease = self.owner.admit().map_err(|_| CommandError::Revoked)?;
             let host = self.host.upgrade().ok_or(CommandError::Draining)?;
-            host.plugin_usage_models(call, input).await
+            host.plugin_usage_activity(call, input).await
         })
     }
 }
diff --git a/crates/runtime-host/tests/fixtures/background-plugin.mjs b/crates/runtime-host/tests/fixtures/background-plugin.mjs
index f67b973ac1..8d4e40fe8a 100644
--- a/crates/runtime-host/tests/fixtures/background-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/background-plugin.mjs
@@ -49,7 +49,7 @@ export default async function (ctx) {
       input.read
     );
     try {
-      const page = await ctx.withAuthorization(input.grant, (call) => call.usage.models(read));
+      const page = await ctx.withAuthorization(input.grant, (call) => call.usage.activity(read));
       return JSON.parse(JSON.stringify({ page }));
     } catch (error) {
       return { error: error.code };
diff --git a/crates/runtime-host/tests/fixtures/host-plugin.mjs b/crates/runtime-host/tests/fixtures/host-plugin.mjs
index 38fbbeb29b..cabf92a53e 100644
--- a/crates/runtime-host/tests/fixtures/host-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/host-plugin.mjs
@@ -437,18 +437,24 @@ export default async function (ctx) {
             generated.usage.output_tokens !== 5
           )
             throw new Error('nested model result or usage was lost');
-          const usage = await call.usage.models({
+          const usage = await call.usage.activity({
             kind: 'start',
             filter: { from: 0, to: 1e15 },
           });
           if (
             !usage.attempts.some(
               (row) =>
-                row.origin.kind === 'auxiliary' &&
-                row.usage.input_tokens === 3 &&
-                row.usage.output_tokens === 5,
+                row.kind === 'model' &&
+                row.attempt.origin.kind === 'auxiliary' &&
+                row.attempt.usage.input_tokens === 3 &&
+                row.attempt.usage.output_tokens === 5,
             ) ||
-            usage.attempts.some((row) => row.sessionId !== call.invocation.session_id)
+            usage.attempts.some(
+              (row) =>
+                (row.kind === 'model'
+                  ? row.attempt.sessionId
+                  : row.attempt.invocation.session_id) !== call.invocation.session_id,
+            )
           )
             throw new Error('Agent accounting lost usage or escaped its Session');
           try {
@@ -461,7 +467,7 @@ export default async function (ctx) {
             if (error.code !== 'revoked') throw error;
           }
           try {
-            await call.usage.models({
+            await call.usage.activity({
               kind: 'start',
               filter: { from: 0, to: 1e15, sessionId: 'foreign-session' },
             });
diff --git a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
index de4e906cf3..29a432f0cb 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization/usage.rs
@@ -52,12 +52,20 @@ impl Probe {
         let result = read(peer, client, document, &self.grant, start.clone()).await;
         let page = &result["page"];
         assert!(page["total"].as_u64().unwrap() > 0, "{result}");
+        assert!(
+            page["attempts"]
+                .as_array()
+                .unwrap()
+                .iter()
+                .any(|row| row["kind"] == "model")
+        );
         for row in page["attempts"].as_array().unwrap() {
-            let attempt: maka_plugins::usage::ModelAttempt =
+            let activity: maka_plugins::usage::Activity =
                 serde_json::from_value(row.clone()).unwrap();
-            assert_eq!(attempt.cost_usd, None);
-            assert_eq!(attempt.quote.unwrap().provider_id, "openai-compatible");
-            assert!(row.get("prompt").is_none() && row.get("text").is_none());
+            if let maka_plugins::usage::Activity::Model(attempt) = activity {
+                assert_eq!(attempt.cost_usd, None);
+                assert_eq!(attempt.quote.unwrap().provider_id, "openai-compatible");
+            }
         }
         let replay = read(
             peer,
@@ -70,6 +78,32 @@ impl Probe {
         assert_eq!(replay, result, "re-reading preserves the same snapshot");
         self.cursor = page["cursor"].clone();
 
+        // A valid maximum-size literal search must fit in a round-trippable
+        // cursor even when JSON escaping doubles its encoded size.
+        let empty = read(
+            peer,
+            client,
+            document,
+            &self.grant,
+            json!({
+                "kind":"start", "filter":{"from":0,"to":1e15,
+                    "activity":{"search":"\"".repeat(1024),"kind":"tool","status":"rejected"}}
+            }),
+        )
+        .await;
+        assert_eq!(empty["page"]["total"], 0, "{empty}");
+        let repeated = read(
+            peer,
+            client,
+            document,
+            &self.grant,
+            json!({
+                "kind":"continue", "cursor":empty["page"]["cursor"]
+            }),
+        )
+        .await;
+        assert_eq!(repeated, empty);
+
         let session = approve(peer, client, "session", Some("background-session")).await;
         let scoped = read(peer, client, document, &session, start.clone()).await;
         assert!(
@@ -77,7 +111,11 @@ impl Probe {
                 .as_array()
                 .unwrap()
                 .iter()
-                .all(|row| row["sessionId"] == "background-session")
+                .all(|row| if row["kind"] == "model" {
+                    row["attempt"]["sessionId"] == "background-session"
+                } else {
+                    row["attempt"]["invocation"]["session_id"] == "background-session"
+                })
         );
         let denied = read(
             peer,
diff --git a/crates/runtime/src/accounting.rs b/crates/runtime/src/accounting.rs
index 4c75ac0988..d71cb6dcbd 100644
--- a/crates/runtime/src/accounting.rs
+++ b/crates/runtime/src/accounting.rs
@@ -17,11 +17,15 @@
  * under the License.
  */
 
-//! Physical model attempts; independent of conversation membership and current prices.
+//! Physical accounting; independent of conversation membership and current prices.
 
 use crate::{context::ModelPurpose, event::Invocation, execution::ModelBinding, model::ModelUsage};
 use serde::{Deserialize, Serialize};
 use uuid::Uuid;
+mod activity;
+mod tool;
+pub use activity::{Activity, ActivityKind, ActivityStatus, Selection};
+pub use tool::{ToolAttempt, ToolResult, ToolStatus};
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
diff --git a/crates/runtime/src/accounting/activity.rs b/crates/runtime/src/accounting/activity.rs
new file mode 100644
index 0000000000..9b695ff29a
--- /dev/null
+++ b/crates/runtime/src/accounting/activity.rs
@@ -0,0 +1,64 @@
+/*
+ * 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 super::{ModelAttempt, ToolAttempt};
+use serde::{Deserialize, Serialize};
+
+/// A physical model call or tool attempt, never copied conversation membership.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind", content = "attempt", rename_all = "snake_case")]
+pub enum Activity {
+    Model(ModelAttempt),
+    Tool(ToolAttempt),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ActivityKind {
+    Model,
+    Tool,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ActivityStatus {
+    Success,
+    Error,
+    Aborted,
+    Unknown,
+    Rejected,
+}
+
+/// Filters only the activity list, never headline accounting for the range.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Selection {
+    pub kind: Option<ActivityKind>,
+    pub status: Option<ActivityStatus>,
+    #[serde(default)]
+    pub search: String,
+}
+impl Selection {
+    pub fn validate(&self) -> Result<(), &'static str> {
+        if self.search.len() > 1024 || self.search.chars().any(char::is_control) {
+            return Err("Usage search exceeds 1 KiB or contains control characters");
+        }
+        Ok(())
+    }
+}
diff --git a/crates/runtime/src/accounting.rs b/crates/runtime/src/accounting/tool.rs
similarity index 51%
copy from crates/runtime/src/accounting.rs
copy to crates/runtime/src/accounting/tool.rs
index 4c75ac0988..1079451a28 100644
--- a/crates/runtime/src/accounting.rs
+++ b/crates/runtime/src/accounting/tool.rs
@@ -17,65 +17,54 @@
  * under the License.
  */
 
-//! Physical model attempts; independent of conversation membership and current prices.
-
-use crate::{context::ModelPurpose, event::Invocation, execution::ModelBinding, model::ModelUsage};
+use crate::{
+    event::Invocation,
+    execution::ModelBinding,
+    tool_call::{RejectionKind, ToolCallIdentity},
+};
 use serde::{Deserialize, Serialize};
-use uuid::Uuid;
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
-pub enum Outcome {
+pub enum ToolStatus {
     Success,
     Error,
-    Aborted,
-    /// The admission ended without a recorded provider outcome.
     Unknown,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(tag = "kind", rename_all = "snake_case")]
-pub enum AuxiliarySource {
-    Agent {
-        invocation: Invocation,
-        operation_id: String,
-    },
-    HostEffect {
-        id: Uuid,
-    },
-}
-
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
-#[serde(tag = "kind", rename_all = "snake_case")]
-pub enum Origin {
-    Agent {
-        invocation: Invocation,
-        purpose: ModelPurpose,
-    },
-    Auxiliary {
-        source: AuxiliarySource,
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase"
+)]
+pub enum ToolResult {
+    /// No effect was admitted, so no execution duration is reported.
+    Rejected { reason: RejectionKind },
+    Settled {
+        started_at: f64,
+        outcome: ToolStatus,
     },
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase", deny_unknown_fields)]
-pub struct ModelAttempt {
+pub struct ToolAttempt {
     pub request_id: String,
-    pub origin: Origin,
-    pub session_id: Option<String>,
+    pub invocation: Invocation,
+    pub call: ToolCallIdentity,
+    pub name: String,
     pub binding: Option<ModelBinding>,
-    pub model_id: String,
-    pub started_at: f64,
     pub completed_at: f64,
-    pub outcome: Outcome,
-    pub usage: ModelUsage,
-    pub quote: Option<crate::pricing::Quote>,
-    pub cost_usd: Option<f64>,
+    pub result: ToolResult,
 }
-
-impl ModelAttempt {
-    /// Wall-clock adjustments cannot produce negative reported latency.
-    pub fn latency_ms(&self) -> f64 {
-        (self.completed_at - self.started_at).max(0.0)
+impl ToolAttempt {
+    pub fn latency_ms(&self) -> Option<f64> {
+        match &self.result {
+            ToolResult::Rejected { .. } => None,
+            ToolResult::Settled { started_at, .. } => {
+                Some((self.completed_at - started_at).max(0.0))
+            }
+        }
     }
 }
diff --git a/crates/runtime/src/tool_call.rs b/crates/runtime/src/tool_call.rs
index 10ec5bc541..2e6429de92 100644
--- a/crates/runtime/src/tool_call.rs
+++ b/crates/runtime/src/tool_call.rs
@@ -46,6 +46,18 @@ pub fn parse_provider_result_id(id: &str) -> Option<(&str, usize)> {
     (provider_result_id(event, index) == id).then_some((event, index))
 }
 
+/// Refusal category without input-derived explanation text.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RejectionKind {
+    Unavailable,
+    InvalidInput,
+    PolicyDenied,
+    PreparationFailed,
+    ExclusiveConflict,
+    Cancelled,
+}
+
 /// A known refusal before durable dispatch: no tool effect was admitted.
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
 #[serde(tag = "kind", rename_all = "snake_case")]
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 6e80717459..b76447c01e 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen valuation, public Rust/JS scoped model-attempt paging, and shared native/plugin pricing query/CAS edits are implemented. Aggregates and Usage screens/activity pages remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| 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. Aggregates and Usage screens remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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. |
@@ -112,7 +112,7 @@ Original TS plugins are not source-compatible with the new SDK.
 | Skills: tool publication | Skill/SkillSearch are ordinary Contributions, without a package-name privilege. Per-step bindings capture handlers and supporting context together, after tool ceilings; physical retries retain the same snapshot. |
 | WorkHub: precise execution commands | Typed commands carry stable operation IDs, exact targets and expected revisions. Correction freezes plugin intent before exact Host control/submission and atomically commits its business receipt; accepted work settles independently of plugin availability. Queue editing preserves the original submission proof. Plugins receive neither SQL transaction callbacks nor unrestricted execution handles. |
 | WorkHub / Graph / Plan: selectable behavior | Open `BehaviorId` selects a typed Contribution; Graph/Swarm register independently. Host acceptance covers a non-builtin business. Preserve Session defaults and durable per-Turn choices; a requested unavailable behavior fails explicitly. Behavior preparation and input preparation are separate contracts, not one hook bus. |
-| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Public Usage reads expose scoped physical model attempts without conversation bodies. Aggregates and reporting remain. Domain catalog/mutation APIs can be typed plugin Services rather than new kernel methods. |
+| Skills / Web / Recall / Insights: authorized services | Public history provides fixed-fence text paging and archived Session metadata to Rust and JS. Admitted Agent calls may read the trusted profile; Remote/background callers retain scoped history authorization. Public Usage reads expose scoped model/tool attempts and refusals without conversation bodies. Aggregates and reporting remain. Domain catalog/mutation APIs can be typed plugin Services rather than new kernel methods. |
 | Skills / WorkHub / default behavior: business UI and prompt context | Published Clients own the actual Skills picker/management and WorkHub surface through Slots and Remote. Native adapters validate the originating Host and document; connection replacement revokes old Remote leases without replay. Prompt Contributions own business instructions. Inactive features remain visibly unavailable without blocking ordinary chat. |
 | Other TS extension services | New SDK lacks equivalent public registrations/services for LSP routing, commands, Skills/Goals queries, shell environment contributions, Settings definitions, authorization flows and LLM adapter registration; questions/forms and source-input/attachment-copy contracts are public; permission approvals remain Host-owned. Implement domain registries as plugin services where possible, retaining Host authority for sensitive actions. `llm.generate` is not adapter [...]
 
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index 69d0d043fa..36995a15f6 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型调用分页,以及原生/插件共享的报价查询与 CAS 修改。聚合统计、Usage 视图与活动分页仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结估价、公共 Rust/JS 作用域模型/工具混合活动分页,以及原生/插件共享的报价查询与 CAS 修改。聚合统计与 Usage 视图仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
@@ -101,7 +101,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Skills:工具发布 | Skill/SkillSearch 是普通 Contribution,不享有包名特权。每步绑定在工具上限内共同捕获 handler 和支持上下文,物理重试保持原快照。 |
 | WorkHub:精确执行命令 | 类型化命令携带稳定操作 ID、精确目标及预期 revision。纠正先冻结插件意图,再精确控制/提交 Host 工作,最后原子记录业务回执;已接受工作不依赖插件可用性继续结算。队列编辑保留原提交凭证。插件不获得 SQL 事务回调或无限制执行句柄。 |
 | WorkHub/Graph/Plan:可选择的 behavior | 已用开放的 `BehaviorId` 选择类型化 Contribution,Graph/Swarm 独立注册;非内置业务已通过 Host 验收。保留 Session 默认值和持久单 Turn 选择;请求的 behavior 不可用时明确失败。behavior 准备与输入准备是独立契约,不合并为 hook 总线。 |
-| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。公共 Usage 读取提供作用域内的模型物理调用,不暴露对话正文;聚合与报表仍待完成。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
+| Skills/Web/Recall/Insights:授权服务 | Rust/JS 公共历史 API 提供固定水位文本分页及归档 Session 元数据。已准入 Agent 可读受信任 profile,Remote/后台调用保留相应范围的历史授权。公共 Usage 读取提供作用域内的模型/工具物理调用和派发前拒绝,不暴露对话正文;聚合与报表仍待完成。领域目录/修改接口可作为类型化插件 Service,不必成为内核方法。 |
 | Skills/WorkHub/默认行为:业务 UI 与 Prompt 上下文 | 发布的 Client 通过 Slot 和 Remote 拥有真实 Skills 选择器/管理页及 WorkHub 界面。原生适配验证原 Host 与 document;连接换代撤销旧 Remote 租约,不重放调用。Prompt Contribution 拥有业务指令。功能停用明确显示不可用,不阻塞普通聊天。 |
 | 其余 TS 扩展服务 | 新 SDK 尚缺等价的公开 LSP 路由、Commands、Skills/Goals 查询、shell 环境变量 Contribution、Settings 定义、授权流程及 LLM adapter 注册;问题/表单、源输入与附件复制已有公共契约;权限审批仍归 Host。能由插件服务实现的领域注册放在插件侧,敏感行为权威仍归 Host。`llm.generate` 不等于 adapter 注册。 |
 
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index f3e37c7565..55d1c62d60 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -222,7 +222,8 @@ user/project content paths stay separate from private journals.
   Each admission captures its public provider identity and rate decision; estimates commit
   with reported usage and survive rejection, edits and recovery. Public Rust/JS Usage reads
   recheck scoped authorization and return at most 100 rows / 48 KiB under a fixed settlement
-  fence; continuation cursors expire on Host restart. Usage screens are not yet connected.
+  fence, with one filtered activity page across model calls, tool effects and refusals.
+  Continuation cursors expire on Host restart. Aggregates and Usage screens are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 423d438e85..96fed7bc38 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -172,7 +172,7 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
   Rust/JS 插件复用同一报价与修改路径;改价需要 profile 范围的明确授权,Agent 调用本身不授予该权限。
   每次准入冻结公共提供商身份和报价,用量与估价一起提交;回复被拒绝、改价或恢复都不改写历史金额。
   公共 Rust/JS Usage 读取逐次校验作用域授权,在固定结算快照下每页最多返回 100 条/48 KiB;
-  Host 重启使继续读取的游标失效。Usage 页面尚未接入。
+  模型调用、工具执行和派发前拒绝共用筛选与活动分页。Host 重启使游标失效;聚合与 Usage 页面尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,
diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md
index 8bd0fd8bc0..e0c701ef13 100644
--- a/packages/plugin-sdk/README.md
+++ b/packages/plugin-sdk/README.md
@@ -143,7 +143,9 @@ Native endpoints accepting caller-supplied Host paths declare `Endpoint::requiri
 
 ## Usage
 
-`call.usage.models({ kind: 'start', filter: { from, to, sessionId? } })` reads settled physical model attempts, including failed retries and auxiliary calls. Agent calls see their Session; independent calls need `read_usage` authorization for a profile or Session. Responses contain no conversation bodies. Missing usage, price and outcome remain distinct unknowns.
+`call.usage.activity({ kind: 'start', filter: { from, to, sessionId?, activity? } })` reads physical model and tool attempts, including failed retries, auxiliary calls and pre-dispatch refusals. Agent calls see their Session; independent calls need `read_usage` authorization for a profile or Session. Responses contain no conversation bodies. Missing usage, price and outcome remain distinct unknowns.
+
+Activity entries are tagged `model` or `tool`. Optional `activity` filters select `kind`, `status` and a literal ASCII-case-insensitive `search` (at most 1 KiB UTF-8, no control characters). Refusals have no execution duration; unknown tool effects are not cancellations.
 
 Each page contains at most 100 attempts / 48 KiB. `{ kind: 'continue', cursor }` re-reads that page; `nextCursor` advances under the same filter and settlement fence. Host restart invalidates cursors; every read checks current authorization.
 
diff --git a/packages/plugin-sdk/README.zh-CN.md b/packages/plugin-sdk/README.zh-CN.md
index 7c7bfa3c7c..c5f0639f5b 100644
--- a/packages/plugin-sdk/README.zh-CN.md
+++ b/packages/plugin-sdk/README.zh-CN.md
@@ -147,7 +147,9 @@ Remote 回调可以抛出携带 `RemoteFailure.code` 的 `Error`。`outcome_unkn
 
 ## 用量
 
-`call.usage.models({ kind: 'start', filter: { from, to, sessionId? } })` 读取已结算的模型物理调用,包含失败重试和辅助调用。Agent 只读当前 Session;独立调用需要 profile 或 Session 范围的 `read_usage` 授权。结果不包含对话正文,缺失用量、报价和结果分别保持未知。
+`call.usage.activity({ kind: 'start', filter: { from, to, sessionId?, activity? } })` 读取模型和工具的物理调用,包含失败重试、辅助调用及派发前拒绝。Agent 只读当前 Session;独立调用需要 profile 或 Session 范围的 `read_usage` 授权。结果不包含对话正文,缺失用量、报价和结果分别保持未知。
+
+活动通过 `model`/`tool` 标签区分。可选 `activity` 按 `kind`、`status` 和字面文本 `search` 筛选;搜索仅忽略 ASCII 大小写,最多 1 KiB UTF-8,不接受控制字符。派发前拒绝没有执行耗时;副作用未知不等于取消。
 
 每页最多 100 条/48 KiB。`{ kind: 'continue', cursor }` 重读该页,`nextCursor` 在相同筛选条件与结算快照下翻页。Host 重启使游标失效;每次读取都重新检查当前授权。
 
diff --git a/packages/plugin-sdk/src/usage.ts b/packages/plugin-sdk/src/usage.ts
index 089f070283..25f6c4915f 100644
--- a/packages/plugin-sdk/src/usage.ts
+++ b/packages/plugin-sdk/src/usage.ts
@@ -45,19 +45,71 @@ export interface UsagePage {
   /** Re-read the same page. Cursors expire on Host restart and confer no access. */
   cursor: string;
   nextCursor: string | null;
-  attempts: readonly ModelAttempt[];
+  attempts: readonly Activity[];
   total: number;
 }
 
+export interface ToolAttempt {
+  requestId: string;
+  invocation: Invocation;
+  call: {
+    tool_call_id: string;
+    origin:
+      | { kind: 'provider'; step_id: string }
+      | {
+          kind: 'code_mode' | 'code_cell';
+          parent_operation_id: string;
+          parent_tool_call_id: string;
+        }
+      | { kind: 'standalone' }
+      | {
+          kind: 'host_sdk';
+          package_id: string;
+          entry_id: string;
+          activation: string;
+          parent_operation_id: string | null;
+        };
+  };
+  name: string;
+  binding: ModelChoice['model'] | null;
+  completedAt: number;
+  result:
+    | {
+        kind: 'rejected';
+        reason:
+          | 'unavailable'
+          | 'invalid_input'
+          | 'policy_denied'
+          | 'preparation_failed'
+          | 'exclusive_conflict'
+          | 'cancelled';
+      }
+    | { kind: 'settled'; startedAt: number; outcome: 'success' | 'error' | 'unknown' };
+}
+
+export type Activity =
+  | { kind: 'model'; attempt: ModelAttempt }
+  | { kind: 'tool'; attempt: ToolAttempt };
+
+export interface UsageSelection {
+  kind?: 'model' | 'tool' | null;
+  status?: 'success' | 'error' | 'aborted' | 'unknown' | 'rejected' | null;
+  /** Literal ASCII-case-insensitive substring, at most 1 KiB UTF-8; no control characters. */
+  search?: string;
+}
+
 export type UsageRead =
-  | { kind: 'start'; filter: { from: number; to: number; sessionId?: string | null } }
+  | {
+      kind: 'start';
+      filter: { from: number; to: number; sessionId?: string | null; activity?: UsageSelection };
+    }
   | { kind: 'continue'; cursor: string };
 
 export interface Usage {
-  /** Settled physical calls, including failed retries and auxiliary SDK calls.
+  /** Physical calls, including failed retries, auxiliary SDK calls and tool refusals.
    * At most 100 rows / 48 KiB. Missing counters or rates remain unknown.
    * Agent calls see their Session; independent calls need read_usage consent
    * with a profile or Session target. No conversation bodies are exposed.
    */
-  models(input: UsageRead): Promise<UsagePage>;
+  activity(input: UsageRead): Promise<UsagePage>;
 }


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


(maka) 04/09: feat(usage): freeze per-request pricing and durable valuation

Posted by wy...@apache.org.
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

commit 7aad3022a8f1ec99dd42597daede6f8ce43832b7
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 15:55:15 2026 +0800

    feat(usage): freeze per-request pricing and durable valuation
    
    Generated-by: Codex
---
 crates/agent/src/lib.rs                            |  23 ++++
 crates/agent/src/model_attempt.rs                  |   9 +-
 .../client_pricing.rs => agent/src/pricing.rs}     |  27 ++---
 .../agent/tests/integration/code_mode_lifetime.rs  |   1 +
 crates/agent/tests/integration/lifetime.rs         |   1 +
 .../agent/tests/integration/model_interruption.rs  |   1 +
 crates/agent/tests/integration/startup_recovery.rs |   1 +
 .../agent/tests/integration/support/agent_loop.rs  |   1 +
 .../agent/tests/integration/support/code_mode.rs   |   1 +
 crates/agent/tests/integration/support/context.rs  |   1 +
 .../tests/integration/support/image_projection.rs  |   1 +
 crates/config/src/pricing.rs                       |  53 +++++----
 crates/config/tests/pricing.rs                     |  27 +++--
 crates/event-log/migrations/0001_runtime.sql       |  17 ++-
 crates/event-log/src/append.rs                     |   2 +
 crates/event-log/src/usage.rs                      |  10 +-
 crates/event-log/src/usage/auxiliary.rs            |  13 ++-
 crates/event-log/src/usage/valuation.rs            | 118 +++++++++++++++++++
 crates/event-log/tests/auxiliary_usage.rs          |  41 +++++--
 crates/protocol/src/pricing.rs                     |  16 ++-
 crates/runtime-host/src/execution.rs               |   4 +-
 crates/runtime-host/src/execution/compact.rs       |   1 +
 .../runtime-host/src/execution/handoff/prepare.rs  |   1 +
 .../runtime-host/src/execution/plugins/effects.rs  |   6 +
 crates/runtime-host/src/execution/plugins/llm.rs   |   8 +-
 .../src/execution/plugins/llm/generate.rs          |   3 +-
 crates/runtime-host/src/execution/prepare.rs       |   1 +
 crates/runtime-host/src/execution/pricing.rs       |  45 ++++++++
 crates/runtime-host/src/execution/provider.rs      |   2 +
 .../runtime-host/src/execution/resume/prepare.rs   |   1 +
 .../tests/integration/client_pricing.rs            | 127 +++++++++++++++++++++
 .../integration/javascript_plugins/metering.rs     |   4 +
 crates/runtime/src/event_write.rs                  |  18 +++
 crates/runtime/src/pricing.rs                      |   3 +
 crates/runtime/src/pricing/quote.rs                |  59 ++++++++++
 docs/rust-parity.md                                |   2 +-
 docs/rust-parity.zh-CN.md                          |   2 +-
 docs/rust-runtime.md                               |   3 +-
 docs/rust-runtime.zh-CN.md                         |   3 +-
 39 files changed, 584 insertions(+), 73 deletions(-)

diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs
index 5f008194d9..2f8fda699e 100644
--- a/crates/agent/src/lib.rs
+++ b/crates/agent/src/lib.rs
@@ -26,6 +26,7 @@ mod handoff;
 pub use handoff::{HandoffGate, HandoffReservation, HeldHandoff, PendingSeal};
 mod history;
 mod model_attempt;
+pub mod pricing;
 mod request_composition;
 pub use history::project as project_model_history;
 mod prune;
@@ -75,6 +76,8 @@ pub enum RunError {
 }
 
 pub struct RunInput {
+    /// Public access-path identity; not the transport protocol or adapter name.
+    pub provider_id: String,
     pub invocation: Invocation,
     pub context: Option<maka_runtime::context::ModelRequestContext>,
     pub work: RunWork,
@@ -110,6 +113,7 @@ pub enum RunWork {
 }
 
 struct Inner {
+    pricing: Option<Arc<dyn pricing::Pricing>>,
     log: Arc<EventLog>,
     model: ModelExecutor,
     cells: CodeExecutor,
@@ -137,7 +141,26 @@ impl PreparedContinuation {
 
 impl Engine {
     pub fn new(log: Arc<EventLog>, model: ModelExecutor, cells: CodeExecutor) -> Self {
+        Self::create(log, model, cells, None)
+    }
+
+    pub fn with_pricing(
+        log: Arc<EventLog>,
+        model: ModelExecutor,
+        cells: CodeExecutor,
+        pricing: Arc<dyn pricing::Pricing>,
+    ) -> Self {
+        Self::create(log, model, cells, Some(pricing))
+    }
+
+    fn create(
+        log: Arc<EventLog>,
+        model: ModelExecutor,
+        cells: CodeExecutor,
+        pricing: Option<Arc<dyn pricing::Pricing>>,
+    ) -> Self {
         Self(Arc::new(Inner {
+            pricing,
             log,
             model,
             cells,
diff --git a/crates/agent/src/model_attempt.rs b/crates/agent/src/model_attempt.rs
index 7f779d6eb2..ac1d17465f 100644
--- a/crates/agent/src/model_attempt.rs
+++ b/crates/agent/src/model_attempt.rs
@@ -263,7 +263,14 @@ async fn execute_once(
                 .map(|baseline| baseline.event_id.clone()),
         },
     );
-    let write = maka_runtime::event::EventWrite::plain(event)?.with_composition(composition)?;
+    let mut write = maka_runtime::event::EventWrite::plain(event)?.with_composition(composition)?;
+    if let Some(pricing) = &inner.pricing {
+        write = write.with_quote(
+            pricing
+                .quote(&input.provider_id, &input.provider.model)
+                .await?,
+        )?;
+    }
     use maka_runtime::event::EventSink;
     inner.log.clone().commit(write).await?;
     let result: Result<_, RunError> = async {
diff --git a/crates/runtime-host/tests/integration/client_pricing.rs b/crates/agent/src/pricing.rs
similarity index 59%
copy from crates/runtime-host/tests/integration/client_pricing.rs
copy to crates/agent/src/pricing.rs
index ad645696d2..9cd76008a1 100644
--- a/crates/runtime-host/tests/integration/client_pricing.rs
+++ b/crates/agent/src/pricing.rs
@@ -17,22 +17,15 @@
  * under the License.
  */
 
-use super::support::client_probe::ClientFixture;
+use crate::RunError;
+use futures_util::future::BoxFuture;
+use maka_runtime::pricing::Quote;
 
-#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
-async fn source_client_pages_edits_and_recovers_native_pricing() {
-    let fixture = ClientFixture::new("maka-pricing-client-");
-    for reopened in [false, true] {
-        fixture
-            .run(
-                "--pricing-workspace",
-                reopened,
-                if reopened {
-                    "pricing-reopened"
-                } else {
-                    "pricing-passed"
-                },
-            )
-            .await;
-    }
+/// Embedding-side rate authority; it is sampled per physical request, not per Run.
+pub trait Pricing: Send + Sync {
+    fn quote<'a>(
+        &'a self,
+        provider: &'a str,
+        model: &'a str,
+    ) -> BoxFuture<'a, Result<Quote, RunError>>;
 }
diff --git a/crates/agent/tests/integration/code_mode_lifetime.rs b/crates/agent/tests/integration/code_mode_lifetime.rs
index 42ec974395..8f9699c791 100644
--- a/crates/agent/tests/integration/code_mode_lifetime.rs
+++ b/crates/agent/tests/integration/code_mode_lifetime.rs
@@ -43,6 +43,7 @@ use tokio_util::sync::CancellationToken;
 
 fn input(base: &str, suffix: &str, effect: Arc<SlowEffect>) -> RunInput {
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: Invocation {
diff --git a/crates/agent/tests/integration/lifetime.rs b/crates/agent/tests/integration/lifetime.rs
index b898eaa7f1..22fb5d1cda 100644
--- a/crates/agent/tests/integration/lifetime.rs
+++ b/crates/agent/tests/integration/lifetime.rs
@@ -70,6 +70,7 @@ impl ToolExecutor for Effect {
 
 fn input(base: &str, suffix: &str, effect: Arc<Effect>) -> RunInput {
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: Invocation {
diff --git a/crates/agent/tests/integration/model_interruption.rs b/crates/agent/tests/integration/model_interruption.rs
index f9c4cf6aaf..f8a96b78b6 100644
--- a/crates/agent/tests/integration/model_interruption.rs
+++ b/crates/agent/tests/integration/model_interruption.rs
@@ -39,6 +39,7 @@ use tokio_util::sync::CancellationToken;
 
 fn input(base: &str, suffix: &str) -> RunInput {
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: Invocation {
diff --git a/crates/agent/tests/integration/startup_recovery.rs b/crates/agent/tests/integration/startup_recovery.rs
index 2e50eaa766..3247e12d06 100644
--- a/crates/agent/tests/integration/startup_recovery.rs
+++ b/crates/agent/tests/integration/startup_recovery.rs
@@ -285,6 +285,7 @@ async fn unknown_dispatch_stays_unknown_and_blocks_next_admission_after_reopen()
     let result = engine
         .run(
             RunInput {
+                provider_id: "fixture".into(),
                 main_output_limit: None,
                 context: None,
                 invocation: Invocation {
diff --git a/crates/agent/tests/integration/support/agent_loop.rs b/crates/agent/tests/integration/support/agent_loop.rs
index 16e73caa59..d240c57752 100644
--- a/crates/agent/tests/integration/support/agent_loop.rs
+++ b/crates/agent/tests/integration/support/agent_loop.rs
@@ -74,6 +74,7 @@ impl ToolExecutor for Effect {
 
 pub(crate) fn input(base: &str, suffix: &str, effect: Arc<Effect>) -> RunInput {
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: Invocation {
diff --git a/crates/agent/tests/integration/support/code_mode.rs b/crates/agent/tests/integration/support/code_mode.rs
index 72f3f62901..315e28edf3 100644
--- a/crates/agent/tests/integration/support/code_mode.rs
+++ b/crates/agent/tests/integration/support/code_mode.rs
@@ -104,6 +104,7 @@ pub fn input(base: &str, suffix: &str, effects: Arc<Effects>) -> RunInput {
         handler: ToolHandler::Immediate(effects.clone()),
     })).unwrap();
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: Invocation {
diff --git a/crates/agent/tests/integration/support/context.rs b/crates/agent/tests/integration/support/context.rs
index ed0df8a136..50b0f0bde7 100644
--- a/crates/agent/tests/integration/support/context.rs
+++ b/crates/agent/tests/integration/support/context.rs
@@ -48,6 +48,7 @@ pub fn input(base: &str, id: &str, compact: bool) -> RunInput {
         model: "test".into(),
     });
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: Invocation {
diff --git a/crates/agent/tests/integration/support/image_projection.rs b/crates/agent/tests/integration/support/image_projection.rs
index ac59791b12..54e4264803 100644
--- a/crates/agent/tests/integration/support/image_projection.rs
+++ b/crates/agent/tests/integration/support/image_projection.rs
@@ -54,6 +54,7 @@ pub fn engine(log: Arc<EventLog>) -> Engine {
 
 pub fn input(base: &str, attachments: Value) -> RunInput {
     RunInput {
+        provider_id: "fixture".into(),
         main_output_limit: None,
         context: None,
         invocation: identity("current"),
diff --git a/crates/config/src/pricing.rs b/crates/config/src/pricing.rs
index 39bd98efa7..15c10f5ac2 100644
--- a/crates/config/src/pricing.rs
+++ b/crates/config/src/pricing.rs
@@ -30,6 +30,36 @@ const PAGE_ITEMS: usize = 128;
 const PAGE_ENTRY_BYTES: usize = 47 * 1024;
 
 impl ConfigurationStore {
+    /// Provider identity comes from the selected connection, never its wire adapter.
+    pub async fn quote_model(
+        &self,
+        provider_id: String,
+        model: String,
+    ) -> Result<maka_runtime::pricing::Quote> {
+        let key = format!("{provider_id}:{model}");
+        self.transaction(TransactionMode::Deferred, move |connection| {
+            Box::pin(async move {
+                let revision = revision(connection).await?;
+                let record: Option<String> = sqlx::query_scalar(
+                    "SELECT record_json FROM effective_pricing WHERE model_key = ?",
+                )
+                .bind(key)
+                .fetch_optional(&mut *connection)
+                .await?;
+                let quote = maka_runtime::pricing::Quote {
+                    provider_id,
+                    revision,
+                    pricing: record
+                        .map(|record| serde_json::from_str(&record))
+                        .transpose()?,
+                };
+                quote.validate(&model).map_err(invalid)?;
+                Ok(quote)
+            })
+        })
+        .await
+    }
+
     /// Continuations are valid only while both bundled and custom rates are unchanged.
     pub async fn query_pricing(&self, query: Query) -> Result<Page> {
         self.transaction(TransactionMode::Deferred, move |connection| Box::pin(async move {
@@ -78,29 +108,6 @@ impl ConfigurationStore {
         })).await
     }
 
-    /// A quote is a value, not a live lookup. Persist it with the model accounting fact.
-    pub async fn model_pricing(&self, model_key: String) -> Result<(u64, Option<Pricing>)> {
-        maka_runtime::pricing::validate_key(&model_key).map_err(invalid)?;
-        self.transaction(TransactionMode::Deferred, move |connection| {
-            Box::pin(async move {
-                let revision = revision(connection).await?;
-                let record: Option<String> = sqlx::query_scalar(
-                    "SELECT record_json FROM effective_pricing WHERE model_key = ?",
-                )
-                .bind(model_key)
-                .fetch_optional(&mut *connection)
-                .await?;
-                Ok((
-                    revision,
-                    record
-                        .map(|record| serde_json::from_str(&record))
-                        .transpose()?,
-                ))
-            })
-        })
-        .await
-    }
-
     pub async fn update_pricing(&self, update: Update) -> Result<Updated> {
         if update.expected_revision > MAX_REVISION {
             return Err(invalid("invalid pricing revision"));
diff --git a/crates/config/tests/pricing.rs b/crates/config/tests/pricing.rs
index 33332d5721..016803d842 100644
--- a/crates/config/tests/pricing.rs
+++ b/crates/config/tests/pricing.rs
@@ -53,6 +53,15 @@ fn price(entry: &Entry) -> &Pricing {
     }
 }
 
+async fn quote(store: &ConfigurationStore, key: &str) -> (u64, Option<Pricing>) {
+    let (provider, model) = key.split_once(':').unwrap();
+    let quote = store
+        .quote_model(provider.into(), model.into())
+        .await
+        .unwrap();
+    (quote.revision, quote.pricing)
+}
+
 #[tokio::test]
 async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacement() {
     let temp = tempfile::tempdir().unwrap();
@@ -119,7 +128,7 @@ async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacemen
             .unwrap(),
         Updated::Unchanged { revision: 1 }
     );
-    let captured = store.model_pricing(custom.model_key.clone()).await.unwrap();
+    let captured = quote(&store, &custom.model_key).await;
     assert_eq!(captured, (1, Some(custom.clone())));
     assert!(entries(&store).await.1.iter().any(|entry| *entry
         == Entry::Custom {
@@ -180,7 +189,7 @@ async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacemen
 
     let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
     assert_eq!(
-        store.model_pricing(custom.model_key.clone()).await.unwrap(),
+        quote(&store, &custom.model_key).await,
         (3, Some(custom.clone()))
     );
     assert_eq!(
@@ -195,10 +204,7 @@ async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacemen
             .unwrap(),
         Updated::Committed { revision: 4 }
     );
-    assert_eq!(
-        store.model_pricing(custom.model_key.clone()).await.unwrap(),
-        (4, Some(original))
-    );
+    assert_eq!(quote(&store, &custom.model_key).await, (4, Some(original)));
     assert_eq!(
         captured,
         (1, Some(custom)),
@@ -241,13 +247,6 @@ async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacemen
             ..
         }
     ));
-    assert_eq!(
-        store
-            .model_pricing("fixture:\u{e000}".into())
-            .await
-            .unwrap()
-            .0,
-        5
-    );
+    assert_eq!(quote(&store, "fixture:\u{e000}").await.0, 5);
     store.close().await.unwrap();
 }
diff --git a/crates/event-log/migrations/0001_runtime.sql b/crates/event-log/migrations/0001_runtime.sql
index 522dc26196..a33b6c96f6 100644
--- a/crates/event-log/migrations/0001_runtime.sql
+++ b/crates/event-log/migrations/0001_runtime.sql
@@ -294,6 +294,18 @@ CREATE UNIQUE INDEX invocation_boundary ON event_log(invocation_id, kind)
 
 CREATE INDEX model_usage_requests ON event_log(sequence) WHERE kind = 'model_requested';
 
+-- Quote admission and provider-usage valuation share their source fact's commit.
+-- These immutable records are canonical accounting, not recalculated projections.
+CREATE TABLE model_accounting (
+    request_id TEXT PRIMARY KEY REFERENCES event_log(event_id),
+    quote_json TEXT NOT NULL CHECK(json_valid(quote_json) AND length(CAST(quote_json AS BLOB)) <= 8192)
+);
+CREATE TABLE model_valuations (
+    request_id TEXT PRIMARY KEY REFERENCES model_accounting(request_id),
+    usage_json TEXT NOT NULL CHECK(json_valid(usage_json)),
+    usd REAL CHECK(usd IS NULL OR usd >= 0)
+);
+
 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';
@@ -375,7 +387,10 @@ LEFT JOIN event_log observed ON observed.kind = 'auxiliary_model_usage'
 WHERE started.kind = 'auxiliary_model_started';
 
 CREATE VIEW model_usage AS
-SELECT * FROM agent_model_usage UNION ALL SELECT * FROM auxiliary_model_usage;
+SELECT calls.*, accounting.quote_json, valuation.usd
+FROM (SELECT * FROM agent_model_usage UNION ALL SELECT * FROM auxiliary_model_usage) calls
+LEFT JOIN model_accounting accounting ON accounting.request_id = calls.event_id
+LEFT JOIN model_valuations valuation ON valuation.request_id = calls.event_id;
 
 CREATE UNIQUE INDEX message_steering_identity ON event_log(
     json_extract(event_json, '$.invocation.session_id'),
diff --git a/crates/event-log/src/append.rs b/crates/event-log/src/append.rs
index 4899832d89..a29e7767b7 100644
--- a/crates/event-log/src/append.rs
+++ b/crates/event-log/src/append.rs
@@ -99,6 +99,7 @@ impl EventLog {
             }
             crate::tool_payloads::verify_replay(transaction, write).await?;
             crate::composition::verify_replay(transaction, write).await?;
+            crate::usage::valuation::verify_replay(transaction, write).await?;
             crate::archive::verify_replay(transaction, event).await?;
             return Ok(AppendResult::Existing(sequence_number(sequence)?));
         }
@@ -255,6 +256,7 @@ impl EventLog {
         crate::message_sources::insert(transaction, event).await?;
         crate::tool_payloads::insert(transaction, write).await?;
         crate::composition::insert(transaction, write).await?;
+        crate::usage::valuation::append(transaction, write).await?;
         if matches!(event.fact, Fact::InvocationEnded { .. }) {
             crate::sessions::read_state::mark_unread(
                 transaction,
diff --git a/crates/event-log/src/usage.rs b/crates/event-log/src/usage.rs
index a94519bda5..ef646f298d 100644
--- a/crates/event-log/src/usage.rs
+++ b/crates/event-log/src/usage.rs
@@ -28,6 +28,7 @@ use serde::{Deserialize, Serialize};
 use sqlx::{Connection, Row, sqlite::SqliteRow};
 
 mod auxiliary;
+pub(crate) mod valuation;
 pub use auxiliary::Source as AuxiliarySource;
 
 #[derive(Clone, Debug)]
@@ -72,6 +73,8 @@ pub struct ModelAttempt {
     pub completed_at: f64,
     pub outcome: Outcome,
     pub usage: ModelUsage,
+    pub quote: Option<maka_runtime::pricing::Quote>,
+    pub cost_usd: Option<f64>,
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -130,7 +133,7 @@ impl EventLog {
                     .await?;
                     let rows = sqlx::query(
                         "SELECT event_id, origin, binding, model_id,
-                        started_at, completed_at, outcome, usage
+                        started_at, completed_at, outcome, usage, quote_json, usd
                  FROM model_usage
                  WHERE completed_at >= ?1 AND completed_at <= ?2
                  AND (?3 IS NULL OR session_id = ?3)
@@ -172,6 +175,11 @@ fn read(row: &SqliteRow) -> Result<ModelAttempt, StoreError> {
         completed_at: row.try_get("completed_at")?,
         outcome: serde_json::from_value(serde_json::Value::String(outcome))?,
         usage: serde_json::from_str(row.try_get("usage")?)?,
+        quote: row
+            .try_get::<Option<&str>, _>("quote_json")?
+            .map(serde_json::from_str)
+            .transpose()?,
+        cost_usd: row.try_get("usd")?,
     })
 }
 
diff --git a/crates/event-log/src/usage/auxiliary.rs b/crates/event-log/src/usage/auxiliary.rs
index c888a20237..0d261a0096 100644
--- a/crates/event-log/src/usage/auxiliary.rs
+++ b/crates/event-log/src/usage/auxiliary.rs
@@ -62,7 +62,11 @@ impl EventLog {
     /// Called inside an already-admitted Host SDK effect, before model dispatch.
     /// The source journal supplies the binding; plugins cannot invent accounting
     /// identity by supplying a Session ID or current provider configuration.
-    pub async fn begin_auxiliary_model(&self, source: Source) -> Result<Uuid, StoreError> {
+    pub async fn begin_auxiliary_model(
+        &self,
+        source: Source,
+        quote: Option<maka_runtime::pricing::Quote>,
+    ) -> Result<Uuid, StoreError> {
         self.validate_root()?;
         let commits = self.commits.clone();
         self.connection
@@ -70,6 +74,9 @@ impl EventLog {
                 Box::pin(async move {
                     let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
                     let (binding, session_id) = binding(&mut tx, &source).await?;
+                    if let Some(quote) = &quote {
+                        quote.validate(&binding.model).map_err(invalid)?;
+                    }
                     let record = Admission {
                         source,
                         binding,
@@ -78,6 +85,9 @@ impl EventLog {
                     };
                     let id = Uuid::new_v4();
                     let sequence = insert(&mut tx, id, "auxiliary_model_started", &record).await?;
+                    if let Some(quote) = &quote {
+                        super::valuation::capture(&mut tx, &id.to_string(), quote).await?;
+                    }
                     tx.commit().await.map_err(StoreError::CommitUnknown)?;
                     commits.send_replace(sequence);
                     Ok(id)
@@ -99,6 +109,7 @@ impl EventLog {
                 Box::pin(async move {
                     let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
                     require_pending(&mut tx, id).await?;
+                    super::valuation::value(&mut tx, &id.to_string(), &usage).await?;
                     let sequence = insert(
                         &mut tx,
                         Uuid::new_v4(),
diff --git a/crates/event-log/src/usage/valuation.rs b/crates/event-log/src/usage/valuation.rs
new file mode 100644
index 0000000000..4306215558
--- /dev/null
+++ b/crates/event-log/src/usage/valuation.rs
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+
+//! Immutable request quotes and valuations committed with their source facts.
+use super::invalid;
+use crate::StoreError;
+use maka_runtime::{
+    event::{EventWrite, Fact},
+    model::{ModelEvent, ModelUsage},
+    pricing::Quote,
+};
+use sqlx::SqliteConnection;
+
+pub(crate) async fn capture(
+    connection: &mut SqliteConnection,
+    id: &str,
+    quote: &Quote,
+) -> Result<(), StoreError> {
+    sqlx::query("INSERT INTO model_accounting(request_id, quote_json) VALUES(?, ?)")
+        .bind(id)
+        .bind(serde_json::to_string(quote)?)
+        .execute(connection)
+        .await?;
+    Ok(())
+}
+
+pub(crate) async fn value(
+    connection: &mut SqliteConnection,
+    id: &str,
+    usage: &ModelUsage,
+) -> Result<(), StoreError> {
+    let quote: Option<String> =
+        sqlx::query_scalar("SELECT quote_json FROM model_accounting WHERE request_id = ?")
+            .bind(id)
+            .fetch_optional(&mut *connection)
+            .await?;
+    let Some(quote) = quote else {
+        return Ok(());
+    };
+    let quote: Quote = serde_json::from_str(&quote)?;
+    let usd = quote.estimate(usage).map_err(invalid)?;
+    let usage = serde_json::to_string(usage)?;
+    sqlx::query(
+        "INSERT INTO model_valuations(request_id, usage_json, usd) VALUES(?, ?, ?)
+         ON CONFLICT(request_id) DO NOTHING",
+    )
+    .bind(id)
+    .bind(&usage)
+    .bind(usd)
+    .execute(&mut *connection)
+    .await?;
+    let same: bool = sqlx::query_scalar(
+        "SELECT usage_json = ? AND usd IS ? FROM model_valuations WHERE request_id = ?",
+    )
+    .bind(usage)
+    .bind(usd)
+    .bind(id)
+    .fetch_one(connection)
+    .await?;
+    if !same {
+        return Err(StoreError::EventConflict);
+    }
+    Ok(())
+}
+
+pub(crate) async fn append(
+    connection: &mut SqliteConnection,
+    write: &EventWrite,
+) -> Result<(), StoreError> {
+    let event = write.event();
+    if let Some(quote) = write.quote() {
+        capture(connection, &event.id, quote).await?;
+    }
+    let (step, usage) = match &event.fact {
+        Fact::ModelObserved {
+            step_id,
+            event: ModelEvent::Finished { usage, .. },
+        } => (step_id, usage),
+        Fact::ModelCompleted { step_id, output } => (step_id, &output.usage),
+        _ => return Ok(()),
+    };
+    let id: String = sqlx::query_scalar(
+        "SELECT event_id FROM event_log WHERE kind = 'model_requested' AND operation_id = ? AND invocation_id = ?"
+    ).bind(step).bind(&event.invocation.invocation_id).fetch_one(&mut *connection).await?;
+    value(connection, &id, usage).await
+}
+
+pub(crate) async fn verify_replay(
+    connection: &mut SqliteConnection,
+    write: &EventWrite,
+) -> Result<(), StoreError> {
+    let stored: Option<String> =
+        sqlx::query_scalar("SELECT quote_json FROM model_accounting WHERE request_id = ?")
+            .bind(&write.event().id)
+            .fetch_optional(connection)
+            .await?;
+    let submitted = write.quote().map(serde_json::to_string).transpose()?;
+    if stored != submitted {
+        return Err(StoreError::EventConflict);
+    }
+    Ok(())
+}
diff --git a/crates/event-log/tests/auxiliary_usage.rs b/crates/event-log/tests/auxiliary_usage.rs
index 4fe278c00b..d743a4eafe 100644
--- a/crates/event-log/tests/auxiliary_usage.rs
+++ b/crates/event-log/tests/auxiliary_usage.rs
@@ -69,15 +69,26 @@ async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_fai
     let path = directory.path().join("auxiliary.sqlite");
     let log = EventLog::open(&path).await.unwrap();
     assert!(
-        log.begin_auxiliary_model(AuxiliarySource::HostEffect { id: Uuid::new_v4() })
+        log.begin_auxiliary_model(AuxiliarySource::HostEffect { id: Uuid::new_v4() }, None)
             .await
             .is_err()
     );
     let parent = log.begin_host_effect(request()).await.unwrap();
     let source = AuxiliarySource::HostEffect { id: parent };
-    let id = log.begin_auxiliary_model(source.clone()).await.unwrap();
+    let quote = serde_json::from_value::<maka_runtime::pricing::Quote>(serde_json::json!({
+        "providerId":"fixture", "revision":9, "pricing":{
+            "modelKey":"fixture:chosen-model", "inputUsdPer1M":1, "outputUsdPer1M":2
+        }
+    }))
+    .unwrap();
+    let id = log
+        .begin_auxiliary_model(source.clone(), Some(quote.clone()))
+        .await
+        .unwrap();
     assert!(
-        log.begin_auxiliary_model(source.clone()).await.is_err(),
+        log.begin_auxiliary_model(source.clone(), None)
+            .await
+            .is_err(),
         "one accepted generation cannot be physically dispatched twice"
     );
     let usage = ModelUsage {
@@ -110,13 +121,19 @@ async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_fai
             .await
             .is_err()
     );
-    assert!(log.begin_auxiliary_model(source.clone()).await.is_err());
+    assert!(
+        log.begin_auxiliary_model(source.clone(), None)
+            .await
+            .is_err()
+    );
     let before = log.model_attempts(query(), 0, 100).await.unwrap();
     assert_eq!(before.total, 1);
     let attempt = &before.attempts[0];
     assert_eq!(attempt.origin, Origin::Auxiliary { source });
     assert_eq!(attempt.outcome, Outcome::Error);
     assert_eq!(attempt.usage, usage);
+    assert_eq!(attempt.quote, Some(quote.clone()));
+    assert_eq!(attempt.cost_usd, None, "partial usage is not free");
     let binding = attempt.binding.as_ref().unwrap();
     assert_eq!(binding.connection_slug, "named-connection");
     assert_eq!(binding.model, "chosen-model");
@@ -124,10 +141,18 @@ async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_fai
     // Crash after usage arrives but before the effect or model outcome commits.
     let parent = log.begin_host_effect(request()).await.unwrap();
     let interrupted = log
-        .begin_auxiliary_model(AuxiliarySource::HostEffect { id: parent })
+        .begin_auxiliary_model(
+            AuxiliarySource::HostEffect { id: parent },
+            Some(quote.clone()),
+        )
         .await
         .unwrap();
-    log.observe_auxiliary_model(interrupted, usage.clone())
+    let complete_usage = ModelUsage {
+        input_tokens: Some(1_000_000),
+        output_tokens: Some(2_000_000),
+        ..Default::default()
+    };
+    log.observe_auxiliary_model(interrupted, complete_usage.clone())
         .await
         .unwrap();
     log.close().await.unwrap();
@@ -139,7 +164,9 @@ async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_fai
     assert_eq!(after.total, 2);
     assert_eq!(after.attempts[0].request_id, interrupted.to_string());
     assert_eq!(after.attempts[0].outcome, Outcome::Unknown);
-    assert_eq!(after.attempts[0].usage, usage);
+    assert_eq!(after.attempts[0].usage, complete_usage);
+    assert_eq!(after.attempts[0].quote, Some(quote));
+    assert_eq!(after.attempts[0].cost_usd, Some(5.0));
     assert_eq!(after.attempts[1], *attempt);
     log.close().await.unwrap();
 }
diff --git a/crates/protocol/src/pricing.rs b/crates/protocol/src/pricing.rs
index f6dd6a9329..7abf8f99fc 100644
--- a/crates/protocol/src/pricing.rs
+++ b/crates/protocol/src/pricing.rs
@@ -129,7 +129,21 @@ fn conflict(expected: u64, actual: u64) -> Result<()> {
     Ok(())
 }
 fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
-    serde_json::from_value(value.clone()).map_err(invalid)
+    let mut value = value.clone();
+    for field in [
+        "revision",
+        "offset",
+        "expectedRevision",
+        "actualRevision",
+        "nextOffset",
+    ] {
+        if let Some(number) = value.get_mut(field)
+            && !number.is_null()
+        {
+            *number = crate::codec::count(number, field)?.into();
+        }
+    }
+    serde_json::from_value(value).map_err(invalid)
 }
 fn invalid(error: impl std::fmt::Display) -> ProtocolError {
     ProtocolError::invalid(error.to_string())
diff --git a/crates/runtime-host/src/execution.rs b/crates/runtime-host/src/execution.rs
index 1074d0ce4d..c9dc4a6ccd 100644
--- a/crates/runtime-host/src/execution.rs
+++ b/crates/runtime-host/src/execution.rs
@@ -32,6 +32,7 @@ mod plugins;
 pub(crate) use plugins::ResourceTarget;
 pub(crate) mod permissions;
 mod prepare;
+mod pricing;
 mod processes;
 mod provider;
 mod read;
@@ -129,10 +130,11 @@ impl Executions {
                 log.clone(),
                 shutdown.clone(),
             )),
-            engine: Engine::new(
+            engine: Engine::with_pricing(
                 log.clone(),
                 models.clone(),
                 CodeExecutor::new(4, CellLimits::default())?,
+                Arc::new(pricing::Prices(configuration.clone())),
             ),
             log,
             models,
diff --git a/crates/runtime-host/src/execution/compact.rs b/crates/runtime-host/src/execution/compact.rs
index f437d41140..f21c2e5d5c 100644
--- a/crates/runtime-host/src/execution/compact.rs
+++ b/crates/runtime-host/src/execution/compact.rs
@@ -81,6 +81,7 @@ impl Executions {
         .await?;
         let snapshot = self
             .launch(RunInput {
+                provider_id: provider.provider_id,
                 invocation: Invocation {
                     session_id: input.session_id,
                     turn_id: input.turn_id,
diff --git a/crates/runtime-host/src/execution/handoff/prepare.rs b/crates/runtime-host/src/execution/handoff/prepare.rs
index e48606afd6..1fd7ed1286 100644
--- a/crates/runtime-host/src/execution/handoff/prepare.rs
+++ b/crates/runtime-host/src/execution/handoff/prepare.rs
@@ -193,6 +193,7 @@ impl PreparedHandoff {
         )
         .await?;
         let run = RunInput {
+            provider_id: observed.provider_id.clone(),
             invocation: self.pause.intent.successor(&self.source.invocation),
             request_fingerprint: None,
             provider: observed.config.clone(),
diff --git a/crates/runtime-host/src/execution/plugins/effects.rs b/crates/runtime-host/src/execution/plugins/effects.rs
index 48601dbf73..b4ed5d6cb0 100644
--- a/crates/runtime-host/src/execution/plugins/effects.rs
+++ b/crates/runtime-host/src/execution/plugins/effects.rs
@@ -430,6 +430,11 @@ impl Executions {
                     let provider = provider
                         .admit(&host.oauth)
                         .map_err(|error| failed(error.message))?;
+                    let quote = host
+                        .configuration
+                        .quote_model(provider.provider_id.clone(), provider.config.model.clone())
+                        .await
+                        .map_err(|error| ToolError::Persistence(error.to_string()))?;
                     super::llm::generate(
                         models,
                         super::llm::request(provider, input),
@@ -437,6 +442,7 @@ impl Executions {
                         cancellation,
                         host.log.clone(),
                         maka_event_log::usage::AuxiliarySource::HostEffect { id },
+                        quote,
                     )
                     .await
                 })
diff --git a/crates/runtime-host/src/execution/plugins/llm.rs b/crates/runtime-host/src/execution/plugins/llm.rs
index 588ebab87d..ab7c87c5cb 100644
--- a/crates/runtime-host/src/execution/plugins/llm.rs
+++ b/crates/runtime-host/src/execution/plugins/llm.rs
@@ -219,6 +219,7 @@ impl Executions {
         .map_err(|error| Error::Host(error.to_string()))?;
         let evidence =
             serde_json::to_value(&input).map_err(|error| Error::Invalid(error.to_string()))?;
+        let provider_id = prepared.provider_id.clone();
         let request = request(prepared, input);
         let adapter = maka_model::adapters::resolve(
             &self
@@ -236,9 +237,14 @@ impl Executions {
             operation_id: operation_id.clone(),
         };
         let log = self.log.clone();
+        let prices = self.configuration.clone();
         let effect = PreparedEffect::new(move |cancellation| {
             Box::pin(async move {
-                generate(models, request, adapter, cancellation, log, source)
+                let quote = prices
+                    .quote_model(provider_id, request.provider.model.clone())
+                    .await
+                    .map_err(|error| ToolError::Persistence(error.to_string()))?;
+                generate(models, request, adapter, cancellation, log, source, quote)
                     .await
                     .map(|output| ToolOutput::Model(Box::new(output)).into())
             })
diff --git a/crates/runtime-host/src/execution/plugins/llm/generate.rs b/crates/runtime-host/src/execution/plugins/llm/generate.rs
index 5efa68245b..556d8a5465 100644
--- a/crates/runtime-host/src/execution/plugins/llm/generate.rs
+++ b/crates/runtime-host/src/execution/plugins/llm/generate.rs
@@ -37,12 +37,13 @@ pub(crate) async fn generate(
     cancellation: CancellationToken,
     log: Arc<EventLog>,
     source: AuxiliarySource,
+    quote: maka_runtime::pricing::Quote,
 ) -> Result<ModelGeneration, ToolError> {
     if cancellation.is_cancelled() {
         return Err(failed("model cancelled before dispatch"));
     }
     let id = log
-        .begin_auxiliary_model(source)
+        .begin_auxiliary_model(source, Some(quote))
         .await
         .map_err(persistence)?;
     let model_id = request.provider.model.clone();
diff --git a/crates/runtime-host/src/execution/prepare.rs b/crates/runtime-host/src/execution/prepare.rs
index 011cbae284..d85a1be31e 100644
--- a/crates/runtime-host/src/execution/prepare.rs
+++ b/crates/runtime-host/src/execution/prepare.rs
@@ -203,6 +203,7 @@ impl Executions {
         let tools = tools::select_editing(model.tools, provider.editing_tools);
         let max_steps = usize::try_from(input.max_steps.unwrap_or(64)).map_err(internal)?;
         Ok(PreparedRun::Model(Box::new(RunInput {
+            provider_id: provider.provider_id,
             invocation: invocation.clone(),
             work: RunWork::Message {
                 source_messages,
diff --git a/crates/runtime-host/src/execution/pricing.rs b/crates/runtime-host/src/execution/pricing.rs
new file mode 100644
index 0000000000..3be6394ee6
--- /dev/null
+++ b/crates/runtime-host/src/execution/pricing.rs
@@ -0,0 +1,45 @@
+/*
+ * 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_agent::{RunError, pricing::Pricing};
+use maka_config::{ConfigError, ConfigurationStore};
+use maka_event_log::connection::BoxFuture;
+use maka_runtime::{event::CommitError, pricing::Quote};
+use std::sync::Arc;
+
+pub(super) struct Prices(pub Arc<ConfigurationStore>);
+impl Pricing for Prices {
+    fn quote<'a>(
+        &'a self,
+        provider: &'a str,
+        model: &'a str,
+    ) -> BoxFuture<'a, Result<Quote, RunError>> {
+        Box::pin(async move {
+            self.0
+                .quote_model(provider.into(), model.into())
+                .await
+                .map_err(|error| match error {
+                    ConfigError::CommitUnknown => {
+                        CommitError::OutcomeUnknown(error.to_string()).into()
+                    }
+                    _ => RunError::Internal(format!("model pricing unavailable: {error}")),
+                })
+        })
+    }
+}
diff --git a/crates/runtime-host/src/execution/provider.rs b/crates/runtime-host/src/execution/provider.rs
index 156c167eb9..38eb443201 100644
--- a/crates/runtime-host/src/execution/provider.rs
+++ b/crates/runtime-host/src/execution/provider.rs
@@ -34,6 +34,7 @@ mod options;
 mod output;
 
 pub(super) struct PreparedProvider {
+    pub provider_id: String,
     pub tool_mode: maka_runtime::execution::ToolMode,
     pub editing_tools: maka_runtime::execution::EditingTools,
     pub config: ProviderConfig,
@@ -216,6 +217,7 @@ pub(super) async fn observe_binding(
         }),
     };
     Ok(PreparedProvider {
+        provider_id: row.provider_type.clone(),
         tool_mode,
         editing_tools,
         binding,
diff --git a/crates/runtime-host/src/execution/resume/prepare.rs b/crates/runtime-host/src/execution/resume/prepare.rs
index b7faf76d3a..a44e577d93 100644
--- a/crates/runtime-host/src/execution/resume/prepare.rs
+++ b/crates/runtime-host/src/execution/resume/prepare.rs
@@ -97,6 +97,7 @@ impl Executions {
         }
         configuration.system_prompt = system_prompt;
         Ok(RunInput {
+            provider_id: provider.provider_id,
             invocation: Invocation {
                 session_id: session_id.clone(),
                 turn_id,
diff --git a/crates/runtime-host/tests/integration/client_pricing.rs b/crates/runtime-host/tests/integration/client_pricing.rs
index ad645696d2..50b2dfc3ab 100644
--- a/crates/runtime-host/tests/integration/client_pricing.rs
+++ b/crates/runtime-host/tests/integration/client_pricing.rs
@@ -18,6 +18,14 @@
  */
 
 use super::support::client_probe::ClientFixture;
+use super::support::{
+    message_recovery::{Provider, configure},
+    peer::Peer,
+};
+use maka_runtime_host::server::{Host, local::LocalListener};
+use serde_json::json;
+use std::time::Duration;
+use tokio_util::sync::CancellationToken;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
 async fn source_client_pages_edits_and_recovers_native_pricing() {
@@ -36,3 +44,122 @@ async fn source_client_pages_edits_and_recovers_native_pricing() {
             .await;
     }
 }
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn in_flight_model_quotes_are_frozen_and_next_admission_observes_rate_changes() {
+    let fixture = ClientFixture::new("maka-pricing-flight-");
+    let (provider, mut requests) = Provider::controlled_with_usage(100, 20).await;
+    let model = configure(&fixture, &provider.base_url).await;
+    let host = Host::open(fixture.owner()).await.unwrap();
+    #[cfg(unix)]
+    let endpoint = fixture.workspace.parent().unwrap().join("pricing.sock");
+    #[cfg(windows)]
+    let endpoint =
+        std::path::PathBuf::from(format!(r"\\.\pipe\maka-pricing-{}", uuid::Uuid::new_v4()));
+    let cancel = CancellationToken::new();
+    let server = tokio::spawn(
+        LocalListener::bind(&endpoint)
+            .unwrap()
+            .serve(host.clone(), cancel.clone()),
+    );
+    let mut peer = Peer::new(host, "pricing-flight").await;
+    let created = peer
+        .rpc(
+            "session.create",
+            json!({
+                "sessionId":"priced", "workspace":{"kind":"host_path","path":fixture.workspace},
+                "modelTarget":{"kind":"explicit","connectionId":model.connection_id,
+                    "connectionSlug":model.connection_slug,"model":model.model}
+            }),
+        )
+        .await;
+    assert_eq!(created["ok"], true, "{created}");
+    let mutation = |revision, rate| {
+        json!({
+            "expectedRevision":revision, "mutation":{"kind":"upsert", "pricing":{
+                "modelKey":"openai-compatible:fixture-model", "inputUsdPer1M":rate, "outputUsdPer1M":rate
+            }}
+        })
+    };
+    let first = peer.rpc("pricing.mutate", mutation(0, 1.0)).await;
+    assert_eq!(first["result"]["revision"], 1, "{first}");
+    for turn in ["before-edit", "after-edit"] {
+        let started = peer
+            .rpc(
+                "turn.start",
+                json!({"sessionId":"priced","turnId":turn,
+            "content":{"text":"hello"},"maxSteps":2}),
+            )
+            .await;
+        assert_eq!(started["ok"], true, "{started}");
+        let request = tokio::time::timeout(Duration::from_secs(10), requests.recv())
+            .await
+            .unwrap()
+            .unwrap();
+        if turn == "before-edit" {
+            let edit = peer.rpc("pricing.mutate", mutation(1, 10.0)).await;
+            assert_eq!(edit["result"]["revision"], 2, "{edit}");
+        }
+        let rejected = turn == "after-edit";
+        request
+            .reply
+            .send(json!({"index":0,"delta":{"content":"done"},"finish_reason":if rejected {"length"} else {"stop"}}))
+            .unwrap();
+        tokio::time::timeout(Duration::from_secs(10), async {
+            loop {
+                let result = peer
+                    .rpc("turn.query", json!({"sessionId":"priced","turnId":turn}))
+                    .await;
+                assert_eq!(result["ok"], true, "{result}");
+                if result["result"]["status"] == "completed"
+                    || result["result"]["status"] == "failed"
+                {
+                    assert_eq!(
+                        result["result"]["status"],
+                        if rejected { "failed" } else { "completed" },
+                        "{result}"
+                    );
+                    break;
+                }
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+        })
+        .await
+        .unwrap();
+    }
+    peer.close().await;
+    cancel.cancel();
+    tokio::time::timeout(Duration::from_secs(10), server)
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    let log = fixture.log().await;
+    let records = log
+        .model_attempts(
+            maka_event_log::usage::Query {
+                from: 0.0,
+                to: f64::MAX,
+                session_id: Some("priced".into()),
+            },
+            0,
+            100,
+        )
+        .await
+        .unwrap();
+    assert_eq!(records.total, 2);
+    assert_eq!(
+        records.attempts[0].outcome,
+        maka_event_log::usage::Outcome::Error
+    );
+    for (attempt, revision, expected) in [
+        (&records.attempts[0], 2, 0.0012),
+        (&records.attempts[1], 1, 0.00012),
+    ] {
+        let quote = attempt.quote.as_ref().unwrap();
+        assert_eq!(quote.provider_id, "openai-compatible");
+        assert_eq!(quote.revision, revision);
+        assert!((attempt.cost_usd.unwrap() - expected).abs() < 1e-12);
+    }
+    log.close().await.unwrap();
+}
diff --git a/crates/runtime-host/tests/integration/javascript_plugins/metering.rs b/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
index 1ca2a048a7..52687ccc30 100644
--- a/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
+++ b/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
@@ -59,6 +59,10 @@ pub(super) async fn verify(log: &EventLog, expected: usize) {
         "retirement must settle the abandoned model request"
     );
     for attempt in auxiliary {
+        let quote = attempt.quote.as_ref().expect("public SDK request quote");
+        assert_eq!(quote.provider_id, "openai-compatible");
+        assert_eq!(quote.pricing, None, "no invented price for a fixture model");
+        assert_eq!(attempt.cost_usd, None, "unpriced is not free");
         if attempt.outcome == Outcome::Success {
             assert_eq!(attempt.usage.input_tokens, Some(3));
             assert_eq!(attempt.usage.output_tokens, Some(5));
diff --git a/crates/runtime/src/event_write.rs b/crates/runtime/src/event_write.rs
index dab32cdfec..4a5356b1f8 100644
--- a/crates/runtime/src/event_write.rs
+++ b/crates/runtime/src/event_write.rs
@@ -26,6 +26,7 @@ use std::{sync::Arc, time::SystemTime};
 #[derive(Clone, Debug)]
 pub struct EventWrite {
     composition: Option<Arc<crate::composition::FrozenComposition>>,
+    quote: Option<crate::pricing::Quote>,
     event: RuntimeEvent,
     raw_payload: Option<Arc<[u8]>>,
     projection_artifacts: Vec<ProjectionArtifactWrite>,
@@ -168,6 +169,7 @@ impl EventWrite {
         }
         Ok(Self {
             composition: None,
+            quote: None,
             event,
             raw_payload: None,
             projection_artifacts: Vec::new(),
@@ -195,6 +197,7 @@ impl EventWrite {
         Ok((
             Self {
                 composition: None,
+                quote: None,
                 event: RuntimeEvent {
                     id,
                     recorded_at,
@@ -232,6 +235,21 @@ impl EventWrite {
     pub fn composition(&self) -> Option<&crate::composition::FrozenComposition> {
         self.composition.as_deref()
     }
+    pub fn with_quote(mut self, quote: crate::pricing::Quote) -> Result<Self, CommitError> {
+        let Fact::ModelRequested { model_id, .. } = &self.event.fact else {
+            return Err(CommitError::Rejected(
+                "model quote requires a model request".into(),
+            ));
+        };
+        quote
+            .validate(model_id)
+            .map_err(|error| CommitError::Rejected(error.into()))?;
+        self.quote = Some(quote);
+        Ok(self)
+    }
+    pub fn quote(&self) -> Option<&crate::pricing::Quote> {
+        self.quote.as_ref()
+    }
     pub fn raw_payload(&self) -> Option<&[u8]> {
         self.raw_payload.as_deref()
     }
diff --git a/crates/runtime/src/pricing.rs b/crates/runtime/src/pricing.rs
index 5227124f11..68488c1929 100644
--- a/crates/runtime/src/pricing.rs
+++ b/crates/runtime/src/pricing.rs
@@ -23,6 +23,9 @@ use crate::model::ModelUsage;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
+mod quote;
+pub use quote::Quote;
+
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
 pub enum Query {
diff --git a/crates/runtime/src/pricing/quote.rs b/crates/runtime/src/pricing/quote.rs
new file mode 100644
index 0000000000..78809d106d
--- /dev/null
+++ b/crates/runtime/src/pricing/quote.rs
@@ -0,0 +1,59 @@
+/*
+ * 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.
+ */
+
+//! A request's public provider identity and contemporaneous rate decision.
+//! Missing pricing is captured too: a later catalog edit cannot price old work.
+use super::Pricing;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Quote {
+    pub provider_id: String,
+    pub revision: u64,
+    pub pricing: Option<Pricing>,
+}
+
+impl Quote {
+    pub fn validate(&self, model: &str) -> Result<(), &'static str> {
+        if self.provider_id.is_empty()
+            || self.provider_id.len() > 1024
+            || self.provider_id.chars().any(char::is_control)
+            || self.revision > 9_007_199_254_740_991
+        {
+            return Err("invalid model quote identity");
+        }
+        if let Some(pricing) = &self.pricing {
+            pricing.validate()?;
+            if pricing.model_key != format!("{}:{model}", self.provider_id) {
+                return Err("model quote does not match the requested model");
+            }
+        }
+        Ok(())
+    }
+
+    pub fn estimate(&self, usage: &crate::model::ModelUsage) -> Result<Option<f64>, &'static str> {
+        self.pricing
+            .as_ref()
+            .map(|price| price.estimate(usage))
+            .transpose()
+            .map(Option::flatten)
+    }
+}
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 29d6e26381..f5ca6f392e 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Physical Agent and auxiliary SDK accounting, bounded source reads, and native pricing query/CAS edits are implemented. Public plugin accounting/pricing capabilities, revision-consistent Usage screens/activity pages and per-call valuation remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| Usage / Pricing | Physical Agent and auxiliary SDK accounting, frozen per-call valuation, bounded source reads, and native pricing query/CAS edits are implemented. Public plugin accounting/pricing capabilities and revision-consistent Usage screens/activity pages remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index db16ffdfc2..d7f3c734a2 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、有界事实读取及原生报价查询/CAS 修改。公共插件计量/报价能力、一致版本的 Usage 视图与活动分页、逐调用估价仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、冻结的逐调用估价、有界事实读取及原生报价查询/CAS 修改。公共插件计量/报价能力、一致版本的 Usage 视图与活动分页仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 98311a05ed..10f2f76d72 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -217,7 +217,8 @@ user/project content paths stay separate from private journals.
   outcomes remain unknown. History copies do not duplicate usage, and Session removal
   retains accounting facts. Pricing queries and CAS edits use a bounded, revision-pinned
   catalog; custom rates survive restart and bundled-rate updates invalidate old cursors.
-  Usage screens and per-call valuation are not yet connected.
+  Each admission captures its public provider identity and rate decision; estimates commit
+  with reported usage and survive rejection, edits and recovery. Usage screens are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 5c49af6090..529af2b9c2 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -169,7 +169,8 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
 - 计量按模型物理准入统计,包含失败重试和插件辅助调用。被拒绝的回复仍保留已报告用量;
   缺失计数与未确认结果保持未知。历史复制不重复计量,Session 删除保留计量事实。
   报价支持有界、固定版本的查询与 CAS 修改;自定义报价跨重启保留,内置报价更新使旧分页失效。
-  Usage 页面与逐调用估价尚未接入。
+  每次准入冻结公共提供商身份和报价,用量与估价一起提交;回复被拒绝、改价或恢复都不改写历史金额。
+  Usage 页面尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,


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


(maka) 01/09: feat(session): retire families with durable scoped receipts

Posted by wy...@apache.org.
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

commit a17237fd9635dfaa007f084c39423ffe58031612
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 14:43:18 2026 +0800

    feat(session): retire families with durable scoped receipts
    
    Generated-by: Codex
---
 .../runtime-host-client-operations.test.ts         |  21 +-
 apps/desktop/src/main/runtime-host-client.ts       |  34 +-
 crates/agent/src/runner.rs                         |   5 +-
 crates/event-log/migrations/0001_runtime.sql       |  21 ++
 crates/event-log/src/append.rs                     |  22 +-
 crates/event-log/src/effects.rs                    |   2 +-
 crates/event-log/src/handoff.rs                    |   2 +
 crates/event-log/src/interactions.rs               |   2 +
 crates/event-log/src/lib.rs                        |   2 +
 crates/event-log/src/message_admissions/insert.rs  |   2 +-
 crates/event-log/src/sessions.rs                   |  19 +-
 crates/event-log/src/sessions/admission.rs         |  50 +++
 crates/event-log/src/sessions/copy.rs              |   4 +
 crates/event-log/src/sessions/copy/lifecycle.rs    |  17 -
 crates/event-log/src/sessions/metadata.rs          |   6 +
 crates/event-log/src/sessions/origin.rs            |   2 +-
 crates/event-log/src/sessions/processes.rs         |  67 ++++
 crates/event-log/src/sessions/removal.rs           | 310 +++++++++++++++++++
 crates/event-log/src/sessions/removal/plan.rs      | 298 ++++++++++++++++++
 crates/event-log/src/shell_runs.rs                 |   2 +-
 crates/event-log/src/steering_delivery.rs          |   1 +
 crates/event-log/src/workspace.rs                  |  26 ++
 crates/event-log/tests/archive.rs                  |  88 ++++++
 crates/event-log/tests/message_queue.rs            | 187 +++++++++++
 crates/event-log/tests/session_copy.rs             | 343 ++++++++++++++++++++-
 crates/fs-tools/src/worktree.rs                    |   3 +
 crates/fs-tools/src/worktree/ownership.rs          |   2 +-
 crates/fs-tools/src/worktree/removal.rs            | 132 ++++++++
 crates/fs-tools/tests/worktree.rs                  |  60 ++++
 crates/js-runtime/src/plugin/sdk.js                |   3 +
 crates/plugins/src/execution.rs                    |  19 ++
 crates/plugins/src/execution/removal.rs            |  62 ++++
 crates/protocol/src/authority.rs                   |   1 +
 crates/protocol/src/operation.rs                   |   1 +
 crates/protocol/src/session.rs                     |  21 ++
 crates/runtime-host/src/execution.rs               |  15 +-
 crates/runtime-host/src/execution/control.rs       |  56 ++++
 crates/runtime-host/src/execution/plugins.rs       |  53 +++-
 .../runtime-host/src/execution/plugins/children.rs |  16 +-
 .../runtime-host/src/execution/plugins/removal.rs  | 158 ++++++++++
 crates/runtime-host/src/execution/plugins/root.rs  |   4 +-
 .../src/execution/plugins/workspace.rs             |   2 +-
 crates/runtime-host/src/execution/processes.rs     | 130 ++++++++
 crates/runtime-host/src/execution/removal.rs       | 233 ++++++++++++++
 crates/runtime-host/src/execution/workspaces.rs    |  45 ++-
 .../runtime-host/src/plugins/javascript/bridge.rs  |  12 +
 .../src/plugins/javascript/bridge/wire.rs          |   6 +
 crates/runtime-host/src/plugins/process.rs         |  21 +-
 crates/runtime-host/src/server.rs                  |   1 +
 crates/runtime-host/src/server/authority.rs        |   3 +
 crates/runtime-host/src/server/operations.rs       |   3 +
 crates/runtime-host/src/server/sessions.rs         |  45 ++-
 crates/runtime-host/src/server/sessions/copy.rs    |   1 +
 crates/runtime-host/src/server/sessions/create.rs  |   1 +
 crates/runtime-host/src/server/sessions/removal.rs | 101 ++++++
 .../runtime-host/src/server/sessions/workspace.rs  |   3 +
 .../tests/fixtures/background-plugin.mjs           |  41 +++
 crates/runtime-host/tests/integration/main.rs      |   1 +
 .../tests/integration/session_removal.rs           | 204 ++++++++++++
 crates/runtime/src/event.rs                        |   3 +
 crates/runtime/src/tools/journal.rs                |   7 +-
 docs/rust-parity.md                                |   4 +-
 docs/rust-parity.zh-CN.md                          |   4 +-
 docs/rust-runtime.md                               |  12 +
 docs/rust-runtime.zh-CN.md                         |   9 +
 packages/plugin-sdk/src/execution.ts               |  18 ++
 packages/runtime-host/src/protocol/operations.ts   |   1 +
 .../src/protocol/session-retirement.ts             |  48 +++
 .../src/server/operation-dispatcher.ts             |   5 +-
 69 files changed, 2985 insertions(+), 118 deletions(-)

diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
index ed6cbe7994..bc9f4d4a8b 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts
@@ -144,7 +144,9 @@ test('settles cleanup when its copy target is already absent', async () => {
 test('settles branch cleanup when its target disappears between catalog reads', async () => {
   const { client } = clientWithResponses([
     { kind: 'session', session: session('branch-copy', 1) },
+    { kind: 'missing' },
     { kind: 'session', session: null },
+    { kind: 'missing' },
   ]);
 
   assert.equal(await client.removeSessionCopy('branch-copy'), 'removed');
@@ -263,6 +265,7 @@ test('abandons a remove whose task was restored under it', async () => {
   // and replaying the delete at the fresh revision destroys a task somebody
   // just pulled back out of the archive.
   const { client, requests } = clientWithResponses([
+    { kind: 'missing' },
     { kind: 'session', session: session('session-1', 4, { isArchived: true }) },
     { kind: 'revision_conflict', expectedRevision: 4, actualRevision: 5 },
     { kind: 'session', session: session('session-1', 5, { isArchived: false }) },
@@ -276,7 +279,7 @@ test('abandons a remove whose task was restored under it', async () => {
   });
   assert.deepEqual(
     requests.map(({ operation }) => operation),
-    ['session.catalog.query', 'session.remove', 'session.catalog.query'],
+    ['session.remove.query', 'session.catalog.query', 'session.remove', 'session.catalog.query'],
   );
 });
 
@@ -284,6 +287,7 @@ test('retries a remove through revision churn that left the task archived', asyn
   // Not every conflict is a restore. A task still archived at the fresh
   // revision was only written around, and the delete still means what it did.
   const { client, requests } = clientWithResponses([
+    { kind: 'missing' },
     { kind: 'session', session: session('session-1', 4, { isArchived: true }) },
     { kind: 'revision_conflict', expectedRevision: 4, actualRevision: 5 },
     { kind: 'session', session: session('session-1', 5, { isArchived: true }) },
@@ -308,6 +312,7 @@ test('removes a task that was never archived when no premise was stated', async
   // Deleting an active task from the rail has no archived premise to lose, so
   // the precondition is the caller's to ask for, not the client's to assume.
   const { client } = clientWithResponses([
+    { kind: 'missing' },
     { kind: 'session', session: session('session-1', 4) },
     { kind: 'removed' },
   ]);
@@ -318,6 +323,20 @@ test('removes a task that was never archived when no premise was stated', async
   });
 });
 
+test('recovers a removed Session receipt without recreating a delete request', async () => {
+  for (const raced of [false, true]) {
+    const { client, requests } = clientWithResponses([
+      ...(raced ? [{ kind: 'missing' }, { kind: 'session', session: null }] : []),
+      { kind: 'removed', sessionId: 'session-1', archivedSubtaskCount: 3 },
+    ]);
+    assert.deepEqual(await client.removeSession('session-1', { requireArchived: true }), {
+      disposition: 'removed',
+      archivedSubtaskCount: 3,
+    });
+    assert.equal(requests.some(({ operation }) => operation === 'session.remove'), false);
+  }
+});
+
 test('rebuilds a Runtime Policy mutation from each fresh CAS projection', async () => {
   const initial = createDefaultRuntimePolicy();
   const concurrent = {
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 9e8f346e40..f4fc8da5d0 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -1027,28 +1027,30 @@ export class DesktopRuntimeHostClient {
 
   /**
    * Removes a Session, optionally only while it is still archived.
-   *
-   * Replaying a rejected write at the fresh revision is right for a rename or a
-   * configuration patch — the write means the same thing either way. It is
-   * wrong for a remove: a lifecycle write bumps the revision, so a conflict can
-   * be the task being restored, and replaying then destroys a task whose
-   * deletion nobody asked for any more.
-   *
-   * `requireArchived` states the premise the caller decided on. Re-asserting it
-   * against each fresh read is enough to hold it through the commit, because
-   * the Host serializes the two writes that could disagree: `session.remove`
-   * and `session.lifecycle.set` both enter `#withStableFamily`, which queues
-   * per Session id through the admission gate, and the remove compares the
-   * revision on the way in. So a restore either lands before that comparison —
-   * bumping `metadataVersion` and rejecting the remove — or waits until the
-   * retirement has finished. It cannot land between the check and the delete.
+   * The receipt survives catalog removal. Fresh writes recheck the caller's
+   * archived premise at each CAS revision, so a concurrent restore wins.
    */
   async removeSession(
     sessionId: string,
     options: { requireArchived?: boolean } = {},
   ): Promise<SessionRemoveOutcome> {
+    const receipt = await this.request('session.remove.query', { sessionId });
+    if (receipt.kind === 'removed') {
+      return { disposition: 'removed', archivedSubtaskCount: receipt.archivedSubtaskCount };
+    }
     for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) {
-      const current = await this.#requireSession(sessionId);
+      const current = await this.getSession(sessionId);
+      if (current === null) {
+        // A removal may have committed after the first receipt read.
+        const settled = await this.request('session.remove.query', { sessionId });
+        if (settled.kind === 'removed') {
+          return { disposition: 'removed', archivedSubtaskCount: settled.archivedSubtaskCount };
+        }
+        throw new DesktopRuntimeHostClientError(
+          'session_not_found',
+          `Runtime Host Session not found: ${sessionId}`,
+        );
+      }
       if (options.requireArchived && !current.isArchived) {
         return { disposition: "restored", archivedSubtaskCount: 0 };
       }
diff --git a/crates/agent/src/runner.rs b/crates/agent/src/runner.rs
index cba47a8ec6..6c8f322890 100644
--- a/crates/agent/src/runner.rs
+++ b/crates/agent/src/runner.rs
@@ -148,8 +148,9 @@ pub async fn run(
         }
     }
     .await;
-    let result = if matches!(&result, Ok((outcome, _)) if !matches!(outcome, InvocationOutcome::HandoffPaused { .. }))
-        && cancellation.is_cancelled()
+    let result = if matches!(&result, Err(RunError::Commit(CommitError::Retired)))
+        || (matches!(&result, Ok((outcome, _)) if !matches!(outcome, InvocationOutcome::HandoffPaused { .. }))
+            && cancellation.is_cancelled())
     {
         Err(RunError::Cancelled)
     } else {
diff --git a/crates/event-log/migrations/0001_runtime.sql b/crates/event-log/migrations/0001_runtime.sql
index 490566e053..bb912a6bc2 100644
--- a/crates/event-log/migrations/0001_runtime.sql
+++ b/crates/event-log/migrations/0001_runtime.sql
@@ -44,6 +44,27 @@ CREATE TABLE session_catalog_revision (
     revision INTEGER NOT NULL CHECK(revision >= 0)
 );
 
+-- Retirement fences new admissions before asynchronous resource cleanup. Keep
+-- its identity after cleanup so a lost reply cannot resurrect the Session.
+CREATE TABLE session_retirements (
+    session_id TEXT PRIMARY KEY,
+    remove_session INTEGER NOT NULL DEFAULT 1 CHECK(remove_session IN (0, 1)),
+    completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1))
+);
+
+CREATE TABLE session_removal_receipts (
+    session_id TEXT PRIMARY KEY,
+    plan_json TEXT NOT NULL
+);
+
+-- A Host crash loses native handles, not proof that a process may still write.
+CREATE TABLE session_processes (
+    id TEXT PRIMARY KEY,
+    session_id TEXT NOT NULL,
+    cleaned INTEGER NOT NULL DEFAULT 0 CHECK(cleaned IN (0, 1))
+);
+CREATE INDEX session_processes_pending ON session_processes(session_id) WHERE cleaned=0;
+
 CREATE TABLE session_read_state (
     session_id TEXT PRIMARY KEY REFERENCES session_control(id),
     has_unread INTEGER NOT NULL CHECK(has_unread IN (0, 1)),
diff --git a/crates/event-log/src/append.rs b/crates/event-log/src/append.rs
index 1289bed51b..4899832d89 100644
--- a/crates/event-log/src/append.rs
+++ b/crates/event-log/src/append.rs
@@ -26,7 +26,8 @@ use crate::{EventLog, StoreError, sequence_number};
 
 impl EventLog {
     /// Commit ordered facts atomically, validating each against the preceding
-    /// facts in this transaction. Exact replays return their original sequence.
+    /// facts in this transaction. Exact replays return their original sequence
+    /// until Session removal completes, after which all writes are rejected.
     /// This must not enclose effects: live dispatch and outcome remain separate.
     pub async fn append_batch(&self, events: &[EventWrite]) -> Result<Vec<u64>, CommitError> {
         self.append_batch_checked(events)
@@ -34,6 +35,7 @@ impl EventLog {
             .map_err(|error| match error {
                 StoreError::CommitUnknown(error) => CommitError::OutcomeUnknown(error.to_string()),
                 StoreError::OperationUnknown => CommitError::OutcomeUnknown(error.to_string()),
+                StoreError::SessionRetired => CommitError::Retired,
                 other => CommitError::Rejected(other.to_string()),
             })
     }
@@ -80,6 +82,11 @@ impl EventLog {
         write: &EventWrite,
     ) -> Result<AppendResult, StoreError> {
         let event = write.event();
+        let removal =
+            crate::sessions::removal::read(transaction, &event.invocation.session_id).await?;
+        if removal == Some(crate::sessions::SessionRetirement::Removed) {
+            return Err(StoreError::SessionRetired);
+        }
         let json = serde_json::to_string(event)?;
         let previous: Option<(i64, String)> =
             sqlx::query_as("SELECT sequence, event_json FROM runtime_events WHERE event_id = ?")
@@ -95,6 +102,17 @@ impl EventLog {
             crate::archive::verify_replay(transaction, event).await?;
             return Ok(AppendResult::Existing(sequence_number(sequence)?));
         }
+        if removal.is_some()
+            && matches!(
+                event.fact,
+                Fact::ModelRequested { .. }
+                    | Fact::ToolDispatched { .. }
+                    | Fact::ExecutorStarted { .. }
+                    | Fact::MessageSteered { .. }
+            )
+        {
+            return Err(StoreError::SessionRetired);
+        }
         let id = &event.invocation.invocation_id;
         let sealed: bool = sqlx::query_scalar(
             "SELECT EXISTS(SELECT 1 FROM runtime_events WHERE invocation_id = ? AND kind = 'invocation_ended')",
@@ -107,7 +125,7 @@ impl EventLog {
         ).bind(id).fetch_optional(&mut *transaction).await?;
         match (&event.fact, opening) {
             (Fact::InvocationOpened { .. }, None) => {
-                crate::sessions::copy::retain(transaction, &event.invocation.session_id).await?;
+                crate::sessions::retain(transaction, &event.invocation.session_id).await?;
                 let archived: bool = sqlx::query_scalar(
                     "SELECT EXISTS(SELECT 1 FROM session_control WHERE id = ? AND archived = 1)",
                 )
diff --git a/crates/event-log/src/effects.rs b/crates/event-log/src/effects.rs
index 288301ddc6..ccc4e24c8b 100644
--- a/crates/event-log/src/effects.rs
+++ b/crates/event-log/src/effects.rs
@@ -103,7 +103,7 @@ impl EventLog {
         self.connection.run(move |connection| {
             Box::pin(async move {
             let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
-            if let Some(session) = session { crate::sessions::copy::retain(&mut tx, &session).await?; }
+            if let Some(session) = session { crate::sessions::retain(&mut tx, &session).await?; }
             sqlx::query("INSERT INTO host_effects (id, package_id, scope_id, request) VALUES (?, ?, ?, ?)")
                 .bind(id.to_string()).bind(namespace.package()).bind(String::from(namespace.scope().clone())).bind(raw)
                 .execute(&mut *tx).await?;
diff --git a/crates/event-log/src/handoff.rs b/crates/event-log/src/handoff.rs
index b780d23dd5..a08c7adaba 100644
--- a/crates/event-log/src/handoff.rs
+++ b/crates/event-log/src/handoff.rs
@@ -45,6 +45,8 @@ impl EventLog {
                    json_extract(pause.event_json,'$.fact.outcome.pause.intent.host_epoch')
                  FROM runtime_events pause
                  WHERE pause.sequence > ? AND pause.kind='invocation_ended'
+                   AND NOT EXISTS (SELECT 1 FROM session_retirements r
+                     WHERE r.session_id=json_extract(pause.event_json,'$.invocation.session_id'))
                    AND json_extract(pause.event_json,'$.fact.outcome.kind')='handoff_paused'
                    AND NOT EXISTS (SELECT 1 FROM runtime_events successor
                      WHERE successor.kind='invocation_opened'
diff --git a/crates/event-log/src/interactions.rs b/crates/event-log/src/interactions.rs
index c089b2d7b3..e68bdf60f8 100644
--- a/crates/event-log/src/interactions.rs
+++ b/crates/event-log/src/interactions.rs
@@ -60,6 +60,8 @@ impl EventLog {
                         record.outcome = outcome;
                         return Ok(InteractionCommit { matches, record });
                     }
+                    crate::sessions::removal::require_accepting(&mut tx, &request.session_id)
+                        .await?;
                     permissions::require_revision(&mut tx, &request).await?;
                     sqlx::query("INSERT INTO interaction_requests VALUES (?, ?, ?, ?)")
                         .bind(&request.request_id)
diff --git a/crates/event-log/src/lib.rs b/crates/event-log/src/lib.rs
index 860642c2a1..79ce202419 100644
--- a/crates/event-log/src/lib.rs
+++ b/crates/event-log/src/lib.rs
@@ -89,6 +89,8 @@ pub enum StoreError {
     ShellNotFound,
     #[error("session not found")]
     SessionNotFound,
+    #[error("session retired")]
+    SessionRetired,
     #[error("session has an unsealed invocation")]
     SessionBusy,
     #[error("catalog revision changed from {expected} to {actual}")]
diff --git a/crates/event-log/src/message_admissions/insert.rs b/crates/event-log/src/message_admissions/insert.rs
index dceed493db..56f4a4f915 100644
--- a/crates/event-log/src/message_admissions/insert.rs
+++ b/crates/event-log/src/message_admissions/insert.rs
@@ -112,6 +112,6 @@ pub(crate) async fn insert(
     }
     sqlx::query("INSERT INTO message_admissions (session_id, message_id, position, record_json) VALUES (?, ?, ?, ?)")
         .bind(session).bind(message).bind(position).bind(encoded).execute(&mut *tx).await?;
-    crate::sessions::copy::retain(tx, session).await?;
+    crate::sessions::retain(tx, session).await?;
     crate::message_queue::bump(tx, session).await.map(Some)
 }
diff --git a/crates/event-log/src/sessions.rs b/crates/event-log/src/sessions.rs
index 1b60b9932c..6bbde2cd48 100644
--- a/crates/event-log/src/sessions.rs
+++ b/crates/event-log/src/sessions.rs
@@ -20,12 +20,19 @@
 //! Durable session control metadata. Execution history remains in runtime_events.
 
 mod activity;
+mod admission;
+pub(crate) use admission::retain;
 pub(crate) mod copy;
 pub use copy::{AbandonRevision, SessionCopy, SessionCopyResult};
 mod execution;
 mod metadata;
 mod origin;
+mod processes;
+pub(crate) mod removal;
 pub use origin::PluginSession;
+pub use removal::{
+    RemovalAuthority, RemovalPlan, RemoveFamilyResult, SessionRemovalResult, SessionRetirement,
+};
 pub(crate) mod read_state;
 pub(crate) use activity::{initialize_execution, project_execution, register_functions};
 pub(crate) use execution::advance_revision;
@@ -178,6 +185,12 @@ impl EventLog {
             .run(move |connection| {
                 Box::pin(async move {
                     let mut tx = connection.begin().await?;
+                    if removal::read(&mut tx, &id)
+                        .await?
+                        .is_some_and(SessionRetirement::removes)
+                    {
+                        return Ok(None);
+                    }
                     read(&mut tx, &id).await
                 })
             })
@@ -265,6 +278,7 @@ impl EventLog {
                     }
                     let ids = sqlx::query_scalar::<_, String>(
                         "SELECT id FROM session_control WHERE id > ?
+                         AND NOT EXISTS (SELECT 1 FROM session_retirements WHERE session_id=id AND remove_session=1)
                          AND (? = 0 OR archived = 0)
                          AND (? IS NULL OR id = ?)
                          AND (? IS NULL OR json_extract(configuration, '$.workspace.hostCwd') = ?)
@@ -327,7 +341,8 @@ pub(crate) async fn insert(
     let inserted = sqlx::query(
         "INSERT INTO session_control SELECT ?, ?, 1, ?, ?, 0, ?
          WHERE NOT EXISTS (SELECT 1 FROM session_control WHERE id = ?)
-           AND NOT EXISTS (SELECT 1 FROM session_history_copies WHERE session_id = ?)",
+           AND NOT EXISTS (SELECT 1 FROM session_history_copies WHERE session_id = ?)
+           AND NOT EXISTS (SELECT 1 FROM session_retirements WHERE session_id = ?)",
     )
     .bind(id)
     .bind(fingerprint)
@@ -336,6 +351,7 @@ pub(crate) async fn insert(
     .bind(configuration)
     .bind(id)
     .bind(id)
+    .bind(id)
     .execute(&mut *tx)
     .await?;
     if inserted.rows_affected() != 1 {
@@ -450,6 +466,7 @@ async fn probe<T: DeserializeOwned>(
     id: &str,
     fingerprint: &str,
 ) -> Result<Option<SessionRecord<T>>, StoreError> {
+    removal::require_accepting(connection, id).await?;
     if fingerprint.is_empty() || fingerprint.len() > 512 {
         return Err(invalid("invalid session fingerprint length"));
     }
diff --git a/crates/event-log/src/sessions/admission.rs b/crates/event-log/src/sessions/admission.rs
new file mode 100644
index 0000000000..ebbfc992e9
--- /dev/null
+++ b/crates/event-log/src/sessions/admission.rs
@@ -0,0 +1,50 @@
+/*
+ * 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 sqlx::{Connection, SqliteConnection};
+
+/// Shares the accepted work's transaction: a retiring Session cannot accept new
+/// work, and an accepted use makes a preparing revision no longer disposable.
+pub(crate) async fn retain(
+    connection: &mut SqliteConnection,
+    session: &str,
+) -> Result<(), StoreError> {
+    super::removal::require_accepting(connection, session).await?;
+    super::copy::retain(connection, session).await
+}
+
+impl EventLog {
+    /// Native process acceptance has no canonical invocation. The Host pins its
+    /// admission gate through this commit and actual launch.
+    pub async fn retain_session(&self, session: &str) -> Result<(), StoreError> {
+        self.validate_root()?;
+        super::validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    retain(&mut tx, &session).await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)
+                })
+            })
+            .await
+    }
+}
diff --git a/crates/event-log/src/sessions/copy.rs b/crates/event-log/src/sessions/copy.rs
index 779db70005..86448082f4 100644
--- a/crates/event-log/src/sessions/copy.rs
+++ b/crates/event-log/src/sessions/copy.rs
@@ -146,6 +146,7 @@ impl EventLog {
             ).bind(&request.target_session_id).fetch_optional(&mut *tx).await?;
             if let Some(previous) = previous {
                 if previous != encoded { return Err(StoreError::SessionConflict); }
+                super::removal::require_mutable(&mut tx, &request.target_session_id).await?;
                 super::origin::check(&mut tx, &request.target_session_id, origin.as_ref()).await?;
                 let session = super::read(&mut tx, &request.target_session_id).await?
                     .ok_or(StoreError::SessionNotFound)?;
@@ -217,6 +218,9 @@ impl EventLog {
                 if retained.rows_affected() == 0 { return Err(super::invalid("revision Turn has no editable input")); }
                 crate::artifacts::history::retain_revision(&mut tx, &request.source_session_id, &request.target_session_id, now).await?;
             }
+            // History ownership is not execution admission: a fully archived
+            // source can seed a branch without becoming executable again.
+            super::removal::require_mutable(&mut tx, &request.source_session_id).await?;
             retain(&mut tx, &request.source_session_id).await?;
             let session = super::read(&mut tx, &request.target_session_id).await?
                 .ok_or(StoreError::SessionNotFound)?;
diff --git a/crates/event-log/src/sessions/copy/lifecycle.rs b/crates/event-log/src/sessions/copy/lifecycle.rs
index f022ed9200..42d65e882a 100644
--- a/crates/event-log/src/sessions/copy/lifecycle.rs
+++ b/crates/event-log/src/sessions/copy/lifecycle.rs
@@ -59,23 +59,6 @@ async fn state(
 }
 
 impl EventLog {
-    /// Host-owned native process acceptance has no canonical invocation. The
-    /// caller pins the admission gate through this commit and actual launch.
-    pub async fn retain_session(&self, session: &str) -> Result<(), StoreError> {
-        self.validate_root()?;
-        sessions::validate_id(session)?;
-        let session = session.to_owned();
-        self.connection
-            .run(move |connection| {
-                Box::pin(async move {
-                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
-                    retain(&mut tx, &session).await?;
-                    tx.commit().await.map_err(StoreError::CommitUnknown)
-                })
-            })
-            .await
-    }
-
     /// Only unused revisions can disappear. Acceptance, dependent creation and
     /// this decision serialize on the same durable writer; replies are replayable.
     pub async fn abandon_revision(&self, session: &str) -> Result<AbandonRevision, StoreError> {
diff --git a/crates/event-log/src/sessions/metadata.rs b/crates/event-log/src/sessions/metadata.rs
index 0a9d72dd90..9bdf16f5ca 100644
--- a/crates/event-log/src/sessions/metadata.rs
+++ b/crates/event-log/src/sessions/metadata.rs
@@ -44,6 +44,7 @@ impl EventLog {
         let id = id.to_owned();
         self.connection.run(move |connection| Box::pin(async move {
         let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+        super::removal::require_mutable(&mut tx, &id).await?;
         let record: SessionRecord<T> = read(&mut tx, &id).await?.ok_or(StoreError::SessionNotFound)?;
         let active: bool = sqlx::query_scalar(
             "SELECT EXISTS(SELECT 1 FROM runtime_events AS opening
@@ -58,6 +59,10 @@ impl EventLog {
         if record.archived == archived {
             return Ok(record);
         }
+        if !archived {
+            sqlx::query("DELETE FROM session_retirements WHERE session_id=? AND remove_session=0 AND completed=1")
+                .bind(&id).execute(&mut *tx).await?;
+        }
         let changed = sqlx::query(
             "UPDATE session_control SET archived = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision < ?",
         ).bind(archived).bind(now.max(record.updated_at) as i64).bind(&id)
@@ -126,6 +131,7 @@ impl EventLog {
             .run(move |connection| {
                 Box::pin(async move {
                     let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    super::removal::require_mutable(&mut tx, &id).await?;
                     let mut record: SessionRecord<T> = read(&mut tx, &id)
                         .await?
                         .ok_or(StoreError::SessionNotFound)?;
diff --git a/crates/event-log/src/sessions/origin.rs b/crates/event-log/src/sessions/origin.rs
index 4959b5959a..bd882b28e4 100644
--- a/crates/event-log/src/sessions/origin.rs
+++ b/crates/event-log/src/sessions/origin.rs
@@ -98,7 +98,7 @@ pub(super) async fn insert(
         if !exists {
             return Err(StoreError::SessionNotFound);
         }
-        super::copy::retain(connection, source).await?;
+        super::retain(connection, source).await?;
     }
     sqlx::query("INSERT INTO plugin_sessions VALUES (?, ?, ?, ?, ?, ?)")
         .bind(&origin.session_id)
diff --git a/crates/event-log/src/sessions/processes.rs b/crates/event-log/src/sessions/processes.rs
new file mode 100644
index 0000000000..8ab318725e
--- /dev/null
+++ b/crates/event-log/src/sessions/processes.rs
@@ -0,0 +1,67 @@
+/*
+ * 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 sqlx::Connection;
+use uuid::Uuid;
+
+impl EventLog {
+    /// The native owner holds Host admission through this commit and spawn.
+    /// Incomplete records survive restart: a lost handle is not proof of exit.
+    pub async fn admit_session_process(&self, session: &str, id: Uuid) -> Result<(), StoreError> {
+        self.validate_root()?;
+        super::validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    super::retain(&mut tx, &session).await?;
+                    sqlx::query("INSERT INTO session_processes(id,session_id) VALUES (?,?)")
+                        .bind(id.to_string())
+                        .bind(session)
+                        .execute(&mut *tx)
+                        .await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)
+                })
+            })
+            .await
+    }
+
+    /// Call only after the owned process group/Job and native resources drained.
+    pub async fn clean_session_process(&self, id: Uuid) -> Result<(), StoreError> {
+        self.validate_root()?;
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    let changed = sqlx::query("UPDATE session_processes SET cleaned=1 WHERE id=?")
+                        .bind(id.to_string())
+                        .execute(&mut *tx)
+                        .await?
+                        .rows_affected();
+                    if changed != 1 {
+                        return Err(StoreError::EventConflict);
+                    }
+                    tx.commit().await.map_err(StoreError::CommitUnknown)
+                })
+            })
+            .await
+    }
+}
diff --git a/crates/event-log/src/sessions/removal.rs b/crates/event-log/src/sessions/removal.rs
new file mode 100644
index 0000000000..879c9e2090
--- /dev/null
+++ b/crates/event-log/src/sessions/removal.rs
@@ -0,0 +1,310 @@
+/*
+ * 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 super::{MAX_SAFE_INTEGER, advance_catalog, invalid, validate_id};
+use crate::{EventLog, StoreError};
+use sqlx::{Connection, SqliteConnection};
+
+mod plan;
+pub use plan::{RemovalAuthority, RemovalPlan, RemoveFamilyResult};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SessionRetirement {
+    Removing,
+    Removed,
+    Archiving,
+    Archived,
+}
+
+impl SessionRetirement {
+    pub fn removes(self) -> bool {
+        matches!(self, Self::Removing | Self::Removed)
+    }
+
+    pub fn pending(self) -> bool {
+        matches!(self, Self::Removing | Self::Archiving)
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SessionRemovalResult {
+    Accepted(SessionRetirement),
+    RevisionConflict { expected: u64, actual: u64 },
+}
+
+/// A durable admission fence, not a ban on settling already accepted effects.
+pub(crate) async fn require_accepting(
+    connection: &mut SqliteConnection,
+    session: &str,
+) -> Result<(), StoreError> {
+    if read(connection, session).await?.is_some() {
+        return Err(StoreError::SessionRetired);
+    }
+    Ok(())
+}
+
+pub(crate) async fn require_mutable(
+    connection: &mut SqliteConnection,
+    session: &str,
+) -> Result<(), StoreError> {
+    match read(connection, session).await? {
+        None | Some(SessionRetirement::Archived) => Ok(()),
+        Some(SessionRetirement::Archiving) => Err(StoreError::SessionBusy),
+        Some(_) => Err(StoreError::SessionRetired),
+    }
+}
+
+pub(crate) async fn read(
+    connection: &mut SqliteConnection,
+    session: &str,
+) -> Result<Option<SessionRetirement>, StoreError> {
+    Ok(sqlx::query_as::<_, (bool, bool)>(
+        "SELECT remove_session, completed FROM session_retirements WHERE session_id=?",
+    )
+    .bind(session)
+    .fetch_optional(connection)
+    .await?
+    .map(|(remove, completed)| match (remove, completed) {
+        (true, false) => SessionRetirement::Removing,
+        (true, true) => SessionRetirement::Removed,
+        (false, false) => SessionRetirement::Archiving,
+        (false, true) => SessionRetirement::Archived,
+    }))
+}
+
+impl EventLog {
+    /// Check durable owners before deleting external resources, not just before
+    /// deleting their metadata. The removal fence prevents new owners appearing.
+    pub async fn session_retirement_ready(&self, session: &str) -> Result<bool, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    Ok(read(&mut tx, &session)
+                        .await?
+                        .is_some_and(SessionRetirement::pending)
+                        && settled(&mut tx, &session).await?)
+                })
+            })
+            .await
+    }
+
+    /// Private cleanup metadata remains until resources are released. Ordinary
+    /// Session queries no longer expose a logically removed Session.
+    pub async fn retiring_session<T: serde::de::DeserializeOwned + Send + 'static>(
+        &self,
+        session: &str,
+    ) -> Result<Option<super::SessionRecord<T>>, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    if !read(&mut tx, &session)
+                        .await?
+                        .is_some_and(SessionRetirement::pending)
+                    {
+                        return Ok(None);
+                    }
+                    super::read(&mut tx, &session).await
+                })
+            })
+            .await
+    }
+
+    /// Retrying an accepted removal observes its receipt before today's revision.
+    /// The caller must authorize this exact Session independently of the receipt.
+    pub async fn begin_session_removal(
+        &self,
+        session: &str,
+        expected: u64,
+    ) -> Result<SessionRemovalResult, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        if expected == 0 || expected > MAX_SAFE_INTEGER {
+            return Err(invalid("invalid expected Session revision"));
+        }
+        let session = session.to_owned();
+        let commits = self.commits.clone();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    if let Some(state) = read(&mut tx, &session).await? {
+                        match state {
+                            SessionRetirement::Removing | SessionRetirement::Removed => {
+                                return Ok(SessionRemovalResult::Accepted(state));
+                            }
+                            SessionRetirement::Archiving => return Err(StoreError::SessionBusy),
+                            SessionRetirement::Archived => {}
+                        }
+                    }
+                    let actual: i64 =
+                        sqlx::query_scalar("SELECT revision FROM session_control WHERE id=?")
+                            .bind(&session)
+                            .fetch_optional(&mut *tx)
+                            .await?
+                            .ok_or(StoreError::SessionNotFound)?;
+                    if actual as u64 != expected {
+                        return Ok(SessionRemovalResult::RevisionConflict {
+                            expected,
+                            actual: actual as u64,
+                        });
+                    }
+                    plan::fence(&mut tx, &session, plan::Action::Remove).await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    commits.send_modify(|_| {});
+                    Ok(SessionRemovalResult::Accepted(SessionRetirement::Removing))
+                })
+            })
+            .await
+    }
+
+    pub async fn session_retirement(
+        &self,
+        session: &str,
+    ) -> Result<Option<SessionRetirement>, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| Box::pin(async move { read(connection, &session).await }))
+            .await
+    }
+
+    /// Bounded recovery scan; the fence is durable even if its requester vanished.
+    pub async fn pending_session_retirements(
+        &self,
+        after: Option<&str>,
+    ) -> Result<Vec<String>, StoreError> {
+        self.validate_root()?;
+        if let Some(after) = after {
+            validate_id(after)?;
+        }
+        let after = after.map(str::to_owned);
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    Ok(sqlx::query_scalar(
+                        "SELECT session_id FROM session_retirements WHERE completed=0
+                 AND (?1 IS NULL OR session_id>?1) ORDER BY session_id LIMIT 32",
+                    )
+                    .bind(after)
+                    .fetch_all(connection)
+                    .await?)
+                })
+            })
+            .await
+    }
+
+    /// The Host has drained in-memory owners and cleaned owned workspace resources.
+    /// Recheck durable settlement before dropping control metadata. Original log
+    /// evidence stays available to copies; history ownership is not execution authority.
+    pub async fn finish_session_retirement(
+        &self,
+        session: &str,
+    ) -> Result<SessionRetirement, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    let state = read(&mut tx, &session)
+                        .await?
+                        .ok_or(StoreError::SessionNotFound)?;
+                    if !state.pending() {
+                        return Ok(state);
+                    }
+                    if !settled(&mut tx, &session).await? {
+                        return Ok(state);
+                    }
+                    if state == SessionRetirement::Archiving {
+                        sqlx::query(
+                            "UPDATE session_retirements SET completed=1 WHERE session_id=?",
+                        )
+                        .bind(&session)
+                        .execute(&mut *tx)
+                        .await?;
+                        advance_catalog(&mut tx).await?;
+                        tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                        return Ok(SessionRetirement::Archived);
+                    }
+                    // Membership is retained proof, not a disposable UI projection:
+                    // descendants may pin an archive whose writer inherited its target.
+                    // Verifying that archive/checkpoint still needs the writer's cut.
+                    for statement in [
+                        "DELETE FROM session_history_artifacts WHERE session_id=?",
+                        "DELETE FROM session_revision_sources WHERE session_id=?",
+                        "DELETE FROM transcript_rows WHERE session_id=?",
+                        "DELETE FROM transcript_text WHERE session_id=?",
+                        "DELETE FROM transcript_progress WHERE session_id=?",
+                        "DELETE FROM catalog_messages WHERE session_id=?",
+                        "DELETE FROM session_read_state WHERE session_id=?",
+                        "DELETE FROM artifacts WHERE session_id=?",
+                        "DELETE FROM artifact_catalog WHERE session_id=?",
+                        "DELETE FROM shell_runs WHERE session_id=?",
+                        "DELETE FROM session_processes WHERE session_id=?",
+                        "DELETE FROM message_queue_state WHERE session_id=?",
+                        "DELETE FROM session_control WHERE id=?",
+                    ] {
+                        sqlx::query(statement)
+                            .bind(&session)
+                            .execute(&mut *tx)
+                            .await?;
+                    }
+                    sqlx::query("UPDATE session_retirements SET completed=1 WHERE session_id=?")
+                        .bind(&session)
+                        .execute(&mut *tx)
+                        .await?;
+                    advance_catalog(&mut tx).await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    Ok(SessionRetirement::Removed)
+                })
+            })
+            .await
+    }
+}
+
+async fn settled(connection: &mut SqliteConnection, session: &str) -> Result<bool, StoreError> {
+    let unsettled: bool = sqlx::query_scalar(
+        "SELECT EXISTS(SELECT 1 FROM runtime_events o WHERE o.kind='invocation_opened'
+            AND json_extract(o.event_json, '$.invocation.session_id')=?1
+            AND NOT EXISTS(SELECT 1 FROM runtime_events t
+                WHERE t.invocation_id=o.invocation_id AND t.kind='invocation_ended'))
+         OR EXISTS(SELECT 1 FROM shell_runs WHERE session_id=?1 AND
+            (active=1 OR json_extract(record_json, '$.state.outcome.kind')='orphaned'))
+         OR EXISTS(SELECT 1 FROM session_processes WHERE session_id=?1 AND cleaned=0)
+         OR EXISTS(SELECT 1 FROM host_effects WHERE outcome IS NULL
+            AND json_extract(request, '$.boundary.boundary.sessionId')=?1)
+         OR EXISTS(SELECT 1 FROM interaction_requests r WHERE session_id=?1
+            AND NOT EXISTS(SELECT 1 FROM interaction_outcomes o WHERE o.request_id=r.request_id))
+         OR EXISTS(SELECT 1 FROM message_admissions WHERE session_id=?1)",
+    )
+    .bind(session)
+    .fetch_one(connection)
+    .await?;
+    Ok(!unsettled)
+}
diff --git a/crates/event-log/src/sessions/removal/plan.rs b/crates/event-log/src/sessions/removal/plan.rs
new file mode 100644
index 0000000000..e83ccc15b1
--- /dev/null
+++ b/crates/event-log/src/sessions/removal/plan.rs
@@ -0,0 +1,298 @@
+/*
+ * 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 super::{EventLog, MAX_SAFE_INTEGER, StoreError, invalid, validate_id};
+use serde::{Deserialize, Serialize};
+use sqlx::{Connection, Row, SqliteConnection};
+use std::collections::BTreeSet;
+
+/// A frozen lifecycle plan; only Host-owned lineage and authority dependencies
+/// participate. Plugin business identifiers and display labels are irrelevant.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct RemovalPlan {
+    pub remove: Vec<String>,
+    pub archive: Vec<String>,
+    pub archived_subtask_count: u64,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum RemoveFamilyResult {
+    Accepted(RemovalPlan),
+    RevisionConflict { expected: u64, actual: u64 },
+}
+
+/// Host-issued authority, checked against every directly removed family member
+/// in the commit transaction. Dependency archival is a consequence of retirement.
+pub enum RemovalAuthority {
+    Unmanaged,
+    Granted {
+        namespace: maka_plugins::storage::Namespace,
+        sessions: BTreeSet<String>,
+    },
+}
+
+impl RemovalAuthority {
+    async fn check(
+        &self,
+        connection: &mut SqliteConnection,
+        plan: &RemovalPlan,
+    ) -> Result<(), StoreError> {
+        for session in &plan.remove {
+            let manager: Option<(String, String)> = sqlx::query_as(
+                "SELECT package_id,scope_id FROM plugin_sessions WHERE session_id=? AND managed=1",
+            )
+            .bind(session)
+            .fetch_optional(&mut *connection)
+            .await?;
+            let allowed = match self {
+                Self::Unmanaged => manager.is_none(),
+                Self::Granted {
+                    namespace,
+                    sessions,
+                } => {
+                    sessions.contains(session)
+                        && manager.is_none_or(|(package, scope)| {
+                            package == namespace.package()
+                                && scope == String::from(namespace.scope().clone())
+                        })
+                }
+            };
+            if !allowed {
+                return Err(StoreError::SessionConflict);
+            }
+        }
+        Ok(())
+    }
+}
+
+pub(super) enum Action {
+    Remove,
+    Archive,
+}
+
+impl EventLog {
+    /// Read an accepted removal without requiring the removed catalog record.
+    pub async fn session_removal_receipt(
+        &self,
+        session: &str,
+    ) -> Result<Option<RemovalPlan>, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    receipt(&mut tx, &session).await
+                })
+            })
+            .await
+    }
+
+    /// Preview is advisory; commit recomputes this plan inside its CAS transaction.
+    pub async fn preview_session_removal(&self, session: &str) -> Result<RemovalPlan, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        let session = session.to_owned();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin().await?;
+                    read(&mut tx, &session).await
+                })
+            })
+            .await
+    }
+
+    /// Remove the revision family and archive its dependent families atomically.
+    /// A retry reads the accepted plan before checking any current metadata.
+    pub async fn remove_session_family(
+        &self,
+        session: &str,
+        expected: u64,
+        authority: RemovalAuthority,
+    ) -> Result<RemoveFamilyResult, StoreError> {
+        self.validate_root()?;
+        validate_id(session)?;
+        if expected == 0 || expected > MAX_SAFE_INTEGER {
+            return Err(invalid("invalid expected Session revision"));
+        }
+        let session = session.to_owned();
+        let commits = self.commits.clone();
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+                    if let Some(plan) = receipt(&mut tx, &session).await? {
+                        return Ok(RemoveFamilyResult::Accepted(plan));
+                    }
+                    super::require_mutable(&mut tx, &session).await?;
+                    let actual: i64 =
+                        sqlx::query_scalar("SELECT revision FROM session_control WHERE id=?")
+                            .bind(&session)
+                            .fetch_optional(&mut *tx)
+                            .await?
+                            .ok_or(StoreError::SessionNotFound)?;
+                    if actual as u64 != expected {
+                        return Ok(RemoveFamilyResult::RevisionConflict {
+                            expected,
+                            actual: actual as u64,
+                        });
+                    }
+                    let plan = read(&mut tx, &session).await?;
+                    authority.check(&mut tx, &plan).await?;
+                    for target in &plan.remove {
+                        fence(&mut tx, target, Action::Remove).await?;
+                    }
+                    for target in &plan.archive {
+                        fence(&mut tx, target, Action::Archive).await?;
+                    }
+                    sqlx::query("INSERT INTO session_removal_receipts VALUES (?,?)")
+                        .bind(&session)
+                        .bind(serde_json::to_string(&plan)?)
+                        .execute(&mut *tx)
+                        .await?;
+                    tx.commit().await.map_err(StoreError::CommitUnknown)?;
+                    commits.send_modify(|_| {});
+                    Ok(RemoveFamilyResult::Accepted(plan))
+                })
+            })
+            .await
+    }
+}
+
+async fn receipt(
+    connection: &mut SqliteConnection,
+    session: &str,
+) -> Result<Option<RemovalPlan>, StoreError> {
+    let receipt: Option<String> =
+        sqlx::query_scalar("SELECT plan_json FROM session_removal_receipts WHERE session_id=?")
+            .bind(session)
+            .fetch_optional(&mut *connection)
+            .await?;
+    if let Some(receipt) = receipt {
+        return Ok(Some(serde_json::from_str(&receipt)?));
+    }
+    // Another member's removal may already have retired this identity.
+    Ok(super::read(connection, session)
+        .await?
+        .filter(|state| state.removes())
+        .map(|_| RemovalPlan {
+            remove: vec![session.to_owned()],
+            archive: Vec::new(),
+            archived_subtask_count: 0,
+        }))
+}
+
+async fn read(connection: &mut SqliteConnection, session: &str) -> Result<RemovalPlan, StoreError> {
+    let rows = sqlx::query(
+        "WITH RECURSIVE families AS (
+            SELECT s.id, s.archived, COALESCE(c.state,'committed') AS copy_state,
+                CASE WHEN json_extract(c.lineage_json,'$.kind')='revision'
+                     THEN json_extract(c.lineage_json,'$.root_session_id') ELSE s.id END AS family,
+                p.authority_session_id AS parent
+            FROM session_control s
+            LEFT JOIN session_history_copies c ON c.session_id=s.id
+            LEFT JOIN plugin_sessions p ON p.session_id=s.id
+            WHERE NOT EXISTS(SELECT 1 FROM session_retirements r
+                WHERE r.session_id=s.id AND r.remove_session=1)
+        ), root AS (SELECT family FROM families WHERE id=?1), dependents(id) AS (
+            SELECT id FROM families WHERE family=(SELECT family FROM root)
+            UNION SELECT child.id FROM families child JOIN dependents d
+                ON child.parent=d.id OR child.family=d.id
+        ) SELECT f.id, f.family, f.archived, f.family=(SELECT family FROM root) AS removes
+          FROM families f JOIN dependents d ON f.id=d.id
+          WHERE f.copy_state<>'preparing' OR f.id=?1
+          ORDER BY removes DESC, f.id LIMIT 4097",
+    )
+    .bind(session)
+    .fetch_all(connection)
+    .await?;
+    if rows.is_empty() {
+        return Err(StoreError::SessionNotFound);
+    }
+    if rows.len() > 4096 {
+        return Err(StoreError::PrefixTooLarge);
+    }
+    let mut plan = RemovalPlan {
+        remove: Vec::new(),
+        archive: Vec::new(),
+        archived_subtask_count: 0,
+    };
+    let mut archived_families = BTreeSet::new();
+    for row in rows {
+        let id: String = row.try_get("id")?;
+        if row.try_get::<bool, _>("removes")? {
+            plan.remove.push(id);
+        } else {
+            plan.archive.push(id);
+            if !row.try_get::<bool, _>("archived")? {
+                archived_families.insert(row.try_get::<String, _>("family")?);
+            }
+        }
+    }
+    plan.archived_subtask_count = archived_families.len() as u64;
+    Ok(plan)
+}
+
+pub(super) async fn fence(
+    connection: &mut SqliteConnection,
+    session: &str,
+    action: Action,
+) -> Result<(), StoreError> {
+    if let Some(previous) = super::read(connection, session).await? {
+        match action {
+            Action::Archive => return Ok(()),
+            Action::Remove if previous.removes() => return Ok(()),
+            Action::Remove => {}
+        }
+    }
+    let remove = matches!(action, Action::Remove);
+    sqlx::query(
+        "INSERT INTO session_retirements(session_id,remove_session) VALUES (?,?)
+        ON CONFLICT(session_id) DO UPDATE SET remove_session=excluded.remove_session, completed=0",
+    )
+    .bind(session)
+    .bind(remove)
+    .execute(&mut *connection)
+    .await?;
+    if !remove {
+        sqlx::query("UPDATE session_control SET archived=1 WHERE id=?")
+            .bind(session)
+            .execute(&mut *connection)
+            .await?;
+    }
+    // Accepted queue identities and payload deletion share the lifecycle commit.
+    sqlx::query(
+        "INSERT INTO message_cancellations(session_id,message_id,cancellation_id)
+        SELECT session_id,message_id,?1 FROM message_admissions WHERE session_id=?1",
+    )
+    .bind(session)
+    .execute(&mut *connection)
+    .await?;
+    let cancelled = sqlx::query("DELETE FROM message_admissions WHERE session_id=?")
+        .bind(session)
+        .execute(&mut *connection)
+        .await?
+        .rows_affected();
+    if cancelled > 0 {
+        crate::message_queue::bump(connection, session).await?;
+    }
+    super::super::advance_revision(connection, session).await
+}
diff --git a/crates/event-log/src/shell_runs.rs b/crates/event-log/src/shell_runs.rs
index 75cfaf5ea3..7c7d6e8d78 100644
--- a/crates/event-log/src/shell_runs.rs
+++ b/crates/event-log/src/shell_runs.rs
@@ -93,7 +93,7 @@ impl EventLog {
                     if inserted.rows_affected() != 1 {
                         return Err(StoreError::ShellConflict);
                     }
-                    crate::sessions::copy::retain(&mut tx, &record.session_id).await?;
+                    crate::sessions::retain(&mut tx, &record.session_id).await?;
                     tx.commit().await.map_err(StoreError::CommitUnknown)?;
                     // Publish on the independent SQL owner even if the caller
                     // abandoned its response after admission.
diff --git a/crates/event-log/src/steering_delivery.rs b/crates/event-log/src/steering_delivery.rs
index ca89a371c0..ff06f61082 100644
--- a/crates/event-log/src/steering_delivery.rs
+++ b/crates/event-log/src/steering_delivery.rs
@@ -36,6 +36,7 @@ impl EventLog {
             .map_err(|error| match error {
                 StoreError::CommitUnknown(error) => CommitError::OutcomeUnknown(error.to_string()),
                 StoreError::OperationUnknown => CommitError::OutcomeUnknown(error.to_string()),
+                StoreError::SessionRetired => CommitError::Retired,
                 other => CommitError::Rejected(other.to_string()),
             })
     }
diff --git a/crates/event-log/src/workspace.rs b/crates/event-log/src/workspace.rs
index 4ad475fac0..b51e10c954 100644
--- a/crates/event-log/src/workspace.rs
+++ b/crates/event-log/src/workspace.rs
@@ -34,6 +34,32 @@ pub struct WorkspaceSession {
 }
 
 impl EventLog {
+    /// Ownership, not quiescence: another retained Session keeps a managed
+    /// checkout alive regardless of how many past users shared that directory.
+    pub async fn workspace_has_other_session(
+        &self,
+        cwd: &str,
+        session: &str,
+    ) -> Result<bool, StoreError> {
+        self.validate_root()?;
+        crate::sessions::validate_id(session)?;
+        let (cwd, session) = (cwd.to_owned(), session.to_owned());
+        self.connection
+            .run(move |connection| {
+                Box::pin(async move {
+                    Ok(sqlx::query_scalar(
+                        "SELECT EXISTS(SELECT 1 FROM session_control
+                WHERE json_extract(configuration, '$.workspace.hostCwd')=? AND id<>?)",
+                    )
+                    .bind(cwd)
+                    .bind(session)
+                    .fetch_one(connection)
+                    .await?)
+                })
+            })
+            .await
+    }
+
     pub async fn workspace_fence(&self, cwd: &str) -> Result<WorkspaceFence, StoreError> {
         self.validate_root()?;
         let cwd = cwd.to_owned();
diff --git a/crates/event-log/tests/archive.rs b/crates/event-log/tests/archive.rs
index aa1b40f10b..25709b694a 100644
--- a/crates/event-log/tests/archive.rs
+++ b/crates/event-log/tests/archive.rs
@@ -38,6 +38,81 @@ mod fixtures;
 mod lineage;
 use fixtures::*;
 
+#[tokio::test]
+async fn retirement_fences_new_effects_without_rejecting_accepted_results_or_exact_replay() {
+    use maka_event_log::sessions::SessionRetirement;
+    use maka_runtime::event::CommitError;
+    let directory = tempfile::tempdir().unwrap();
+    let log = EventLog::open(&directory.path().join("retirement.sqlite"))
+        .await
+        .unwrap();
+    log.create_session("session", "create", &json!({}), 1)
+        .await
+        .unwrap();
+    open(&log, "run", false).await;
+    let accepted = request("run", "step", None);
+    let sequence = log.append(&accepted).await.unwrap();
+    let revision = log
+        .get_session::<serde_json::Value>("session")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    log.begin_session_removal("session", revision)
+        .await
+        .unwrap();
+    assert_eq!(log.append(&accepted).await.unwrap(), sequence);
+    let output = serde_json::from_value(json!({
+        "parts":[{"kind":"tool_call","call":{"id":"call","name":"Read","input":{"path":"file"},"provider_executed":false}}],
+        "finish_reason":"tool-calls","usage":{}
+    })).unwrap();
+    log.append(&event(
+        "run",
+        Fact::ModelCompleted {
+            step_id: "step".into(),
+            output,
+        },
+    ))
+    .await
+    .unwrap();
+    assert!(matches!(
+        log.append(&request("run", "next", None)).await,
+        Err(CommitError::Retired)
+    ));
+    assert!(matches!(
+        log.append(&event(
+            "run",
+            Fact::ToolDispatched {
+                operation_id: "step:call".into(),
+                call: ToolCallIdentity::provider("step".into(), "call".into()),
+                name: "Read".into(),
+                input: json!({"path":"file"}),
+            }
+        ))
+        .await,
+        Err(CommitError::Retired)
+    ));
+    log.append(&event(
+        "run",
+        Fact::InvocationEnded {
+            outcome: InvocationOutcome::Cancelled {
+                source: "session_removal".into(),
+            },
+        },
+    ))
+    .await
+    .unwrap();
+    assert_eq!(
+        log.finish_session_retirement("session").await.unwrap(),
+        SessionRetirement::Removed
+    );
+    assert!(matches!(
+        log.append(&accepted).await,
+        Err(CommitError::Retired)
+    ));
+    log.close().await.unwrap();
+}
+
 #[tokio::test]
 async fn archive_atomic_retry_reopen_scope_and_source_integrity() {
     let directory = tempfile::tempdir().unwrap();
@@ -696,6 +771,19 @@ async fn inherited_results_prune_and_compact_without_changing_siblings_or_frozen
             .event_id,
         checkpoint.event().id
     );
+    // Nested pins still need the intermediate writer's inherited ownership and
+    // checkpoint evidence even after its live Session and materials are gone.
+    let revision = log
+        .get_session::<serde_json::Value>("branch")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    log.begin_session_removal("branch", revision).await.unwrap();
+    assert_eq!(
+        log.finish_session_retirement("branch").await.unwrap(),
+        maka_event_log::sessions::SessionRetirement::Removed
+    );
     assert_eq!(
         log.read_model_context("nested", None, 100, 65536)
             .await
diff --git a/crates/event-log/tests/message_queue.rs b/crates/event-log/tests/message_queue.rs
index 4cbee377d6..568b0824f4 100644
--- a/crates/event-log/tests/message_queue.rs
+++ b/crates/event-log/tests/message_queue.rs
@@ -32,6 +32,193 @@ use serde_json::json;
 mod support;
 use support::{admission, append, command, invocation, opening};
 
+#[tokio::test]
+async fn removal_atomically_cancels_pending_work_and_keeps_accepted_settlement_across_restart() {
+    use maka_event_log::sessions::{SessionRemovalResult as Result, SessionRetirement as State};
+    for active in [false, true] {
+        let temp = tempfile::tempdir().unwrap();
+        let path = temp.path().join("removal.sqlite");
+        let log = EventLog::open(&path).await.unwrap();
+        log.create_session("session", "create", &json!({}), 1)
+            .await
+            .unwrap();
+        let process = uuid::Uuid::new_v4();
+        log.admit_session_process("session", process).await.unwrap();
+        let owner = invocation("first");
+        if active {
+            append(&log, &owner, opening()).await;
+        }
+        let pending = admission(
+            &owner,
+            "pending",
+            if active {
+                Disposition::Followup
+            } else {
+                Disposition::TurnStarted
+            },
+        );
+        log.admit_message(pending.clone()).await.unwrap();
+        let revision = log
+            .get_session::<serde_json::Value>("session")
+            .await
+            .unwrap()
+            .unwrap()
+            .revision;
+        assert!(matches!(
+            log.begin_session_removal("session", revision + 1)
+                .await
+                .unwrap(),
+            Result::RevisionConflict { .. }
+        ));
+        let inspect = rusqlite::Connection::open(&path).unwrap();
+        inspect
+            .execute_batch(
+                "CREATE TRIGGER reject_retirement BEFORE INSERT ON message_cancellations
+            BEGIN SELECT RAISE(ABORT, 'cancellation failure'); END;",
+            )
+            .unwrap();
+        assert!(
+            log.begin_session_removal("session", revision)
+                .await
+                .is_err()
+        );
+        assert_eq!(log.session_retirement("session").await.unwrap(), None);
+        assert_eq!(
+            log.pending_messages("session").await.unwrap(),
+            vec![pending.clone()]
+        );
+        inspect
+            .execute_batch("DROP TRIGGER reject_retirement")
+            .unwrap();
+        drop(inspect);
+        assert_eq!(
+            log.begin_session_removal("session", revision)
+                .await
+                .unwrap(),
+            Result::Accepted(State::Removing)
+        );
+        assert!(!log.session_retirement_ready("session").await.unwrap());
+        assert!(
+            log.admit_session_process("session", uuid::Uuid::new_v4())
+                .await
+                .is_err()
+        );
+        assert!(log.message_cancelled("session", "pending").await.unwrap());
+        assert!(
+            log.get_session::<serde_json::Value>("session")
+                .await
+                .unwrap()
+                .is_none()
+        );
+        assert!(
+            log.list_sessions::<serde_json::Value>(None, None, 32)
+                .await
+                .unwrap()
+                .sessions
+                .is_empty()
+        );
+        assert!(
+            log.retiring_session::<serde_json::Value>("session")
+                .await
+                .unwrap()
+                .is_some()
+        );
+        assert!(log.pending_message_sessions(None).await.unwrap().is_empty());
+        assert!(log.admit_message(pending).await.is_err());
+        assert!(matches!(
+            log.retain_session("session").await,
+            Err(StoreError::SessionRetired)
+        ));
+        assert!(
+            log.update_session_metadata(
+                "session",
+                revision + 1,
+                |_: &mut serde_json::Value| Ok(())
+            )
+            .await
+            .is_err()
+        );
+        if active {
+            assert_eq!(
+                log.finish_session_retirement("session").await.unwrap(),
+                State::Removing
+            );
+            append(
+                &log,
+                &owner,
+                Fact::InvocationEnded {
+                    outcome: InvocationOutcome::Cancelled {
+                        source: "session_removal".into(),
+                    },
+                },
+            )
+            .await;
+        }
+        log.close().await.unwrap();
+        let log = EventLog::open(&path).await.unwrap();
+        assert!(
+            !log.session_retirement_ready("session").await.unwrap(),
+            "losing the native process handle does not prove cleanup"
+        );
+        assert_eq!(
+            log.finish_session_retirement("session").await.unwrap(),
+            State::Removing
+        );
+        log.clean_session_process(process).await.unwrap();
+        assert!(log.session_retirement_ready("session").await.unwrap());
+        assert_eq!(
+            log.pending_session_retirements(None).await.unwrap(),
+            ["session"]
+        );
+        assert_eq!(
+            log.begin_session_removal("session", revision)
+                .await
+                .unwrap(),
+            Result::Accepted(State::Removing)
+        );
+        assert_eq!(
+            log.finish_session_retirement("session").await.unwrap(),
+            State::Removed
+        );
+        assert_eq!(
+            log.finish_session_retirement("session").await.unwrap(),
+            State::Removed
+        );
+        assert_eq!(
+            log.begin_session_removal("session", revision)
+                .await
+                .unwrap(),
+            Result::Accepted(State::Removed)
+        );
+        assert!(
+            log.get_session::<serde_json::Value>("session")
+                .await
+                .unwrap()
+                .is_none()
+        );
+        assert!(
+            log.create_session("session", "create", &json!({}), 2)
+                .await
+                .is_err()
+        );
+        assert!(
+            log.append(
+                &EventWrite::plain(RuntimeEvent::new(invocation("late"), opening())).unwrap()
+            )
+            .await
+            .is_err()
+        );
+        assert!(
+            log.pending_session_retirements(None)
+                .await
+                .unwrap()
+                .is_empty()
+        );
+        assert!(log.message_cancelled("session", "pending").await.unwrap());
+        log.close().await.unwrap();
+    }
+}
+
 #[tokio::test]
 async fn queue_edits_cancel_or_deliver_once_with_atomic_revision_and_original_ownership() {
     let temp = tempfile::tempdir().unwrap();
diff --git a/crates/event-log/tests/session_copy.rs b/crates/event-log/tests/session_copy.rs
index 56c62fdec8..af1e298244 100644
--- a/crates/event-log/tests/session_copy.rs
+++ b/crates/event-log/tests/session_copy.rs
@@ -101,6 +101,322 @@ async fn copy(
     *session
 }
 
+#[tokio::test]
+async fn removal_does_not_treat_revision_history_as_management_authority() {
+    use maka_event_log::sessions::{PluginSession, RemovalAuthority, RemoveFamilyResult};
+    use maka_plugins::{composition::Scope, storage::Namespace};
+    let temp = tempfile::tempdir().unwrap();
+    let log = EventLog::open(&temp.path().join("authority.sqlite"))
+        .await
+        .unwrap();
+    let namespace = Namespace::new("owner", Scope::Profile).unwrap();
+    log.create_session("parent", "parent", &json!({}), 1)
+        .await
+        .unwrap();
+    log.create_plugin_session(
+        &PluginSession {
+            session_id: "managed".into(),
+            creator: namespace.clone(),
+            fingerprint: "managed".into(),
+            managed: true,
+            authority_session_id: None,
+        },
+        &json!({}),
+        1,
+    )
+    .await
+    .unwrap();
+    turn(&log, "managed", "source-turn", false).await;
+    let source = log.get_session::<Value>("managed").await.unwrap().unwrap();
+    log.copy_plugin_session(
+        SessionCopy {
+            source_session_id: source.id,
+            target_session_id: "unmanaged-revision".into(),
+            expected_source_revision: source.revision,
+            purpose: maka_runtime::session::CopyPurpose::Revision {
+                turn_id: "source-turn".into(),
+            },
+        },
+        &json!({}),
+        2,
+        PluginSession {
+            session_id: "unmanaged-revision".into(),
+            creator: namespace.clone(),
+            fingerprint: "revision".into(),
+            managed: false,
+            authority_session_id: Some("parent".into()),
+        },
+    )
+    .await
+    .unwrap();
+    log.retain_session("unmanaged-revision").await.unwrap();
+    let revision = log
+        .get_session::<Value>("unmanaged-revision")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    for authority in [
+        RemovalAuthority::Unmanaged,
+        RemovalAuthority::Granted {
+            namespace,
+            sessions: ["unmanaged-revision".into()].into_iter().collect(),
+        },
+    ] {
+        assert!(matches!(
+            log.remove_session_family("unmanaged-revision", revision, authority)
+                .await,
+            Err(StoreError::SessionConflict)
+        ));
+        assert!(log.session_retirement("managed").await.unwrap().is_none());
+        assert!(
+            log.session_removal_receipt("unmanaged-revision")
+                .await
+                .unwrap()
+                .is_none()
+        );
+    }
+    let revision = log
+        .get_session::<Value>("parent")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    let RemoveFamilyResult::Accepted(plan) = log
+        .remove_session_family("parent", revision, RemovalAuthority::Unmanaged)
+        .await
+        .unwrap()
+    else {
+        panic!("unchanged revision");
+    };
+    assert_eq!(plan.archive, ["unmanaged-revision"]);
+    assert!(
+        !log.get_session::<Value>("managed")
+            .await
+            .unwrap()
+            .unwrap()
+            .archived
+    );
+    log.close().await.unwrap();
+}
+
+#[tokio::test]
+async fn family_removal_freezes_one_atomic_plan_and_does_not_rearchive_restored_dependents() {
+    use maka_event_log::sessions::{PluginSession, RemoveFamilyResult, SessionRetirement};
+    use maka_plugins::{composition::Scope, storage::Namespace};
+    use maka_runtime::session::CopyPurpose;
+    use sqlx::Connection;
+    let temp = tempfile::tempdir().unwrap();
+    let path = temp.path().join("family.sqlite");
+    let log = EventLog::open(&path).await.unwrap();
+    log.create_session("root", "root", &json!({}), 1)
+        .await
+        .unwrap();
+    turn(&log, "root", "root-turn", false).await;
+    for target in ["revision", "draft", "branch"] {
+        let source = log.get_session::<Value>("root").await.unwrap().unwrap();
+        copy(
+            &log,
+            SessionCopy {
+                source_session_id: source.id,
+                target_session_id: target.into(),
+                expected_source_revision: source.revision,
+                purpose: if target == "branch" {
+                    CopyPurpose::EmptySideConversation
+                } else {
+                    CopyPurpose::Revision {
+                        turn_id: "root-turn".into(),
+                    }
+                },
+            },
+        )
+        .await;
+        if target == "revision" {
+            log.retain_session(target).await.unwrap();
+        }
+    }
+    let child = |id: &str, parent: &str| PluginSession {
+        session_id: id.into(),
+        creator: Namespace::new("any-package", Scope::Profile).unwrap(),
+        fingerprint: id.into(),
+        managed: false,
+        authority_session_id: Some(parent.into()),
+    };
+    log.create_plugin_session(&child("child", "revision"), &json!({}), 2)
+        .await
+        .unwrap();
+    turn(&log, "child", "child-turn", false).await;
+    let source = log.get_session::<Value>("child").await.unwrap().unwrap();
+    copy(
+        &log,
+        SessionCopy {
+            source_session_id: source.id,
+            target_session_id: "child-revision".into(),
+            expected_source_revision: source.revision,
+            purpose: CopyPurpose::Revision {
+                turn_id: "child-turn".into(),
+            },
+        },
+    )
+    .await;
+    log.create_plugin_session(&child("grandchild", "child-revision"), &json!({}), 3)
+        .await
+        .unwrap();
+    log.create_plugin_session(&child("already-archived", "root"), &json!({}), 4)
+        .await
+        .unwrap();
+    log.set_session_archived::<Value>("already-archived", true, 5)
+        .await
+        .unwrap();
+    let preview = log.preview_session_removal("revision").await.unwrap();
+    assert_eq!(preview.remove, ["revision", "root"]);
+    assert_eq!(
+        preview.archive,
+        ["already-archived", "child", "child-revision", "grandchild"]
+    );
+    assert_eq!(preview.archived_subtask_count, 2);
+    // Preview does not reserve an old dependency set. Admission recomputes it.
+    log.create_plugin_session(&child("late", "root"), &json!({}), 6)
+        .await
+        .unwrap();
+    let revision = log
+        .get_session::<Value>("revision")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    assert!(matches!(
+        log.remove_session_family(
+            "revision",
+            revision + 1,
+            maka_event_log::sessions::RemovalAuthority::Unmanaged
+        )
+        .await
+        .unwrap(),
+        RemoveFamilyResult::RevisionConflict { .. }
+    ));
+    let mut faults = sqlx::SqliteConnection::connect_with(
+        &sqlx::sqlite::SqliteConnectOptions::new().filename(&path),
+    )
+    .await
+    .unwrap();
+    sqlx::query(
+        "CREATE TRIGGER refuse_archive BEFORE INSERT ON session_retirements
+        WHEN NEW.session_id='child' BEGIN SELECT RAISE(ABORT,'archive failure'); END",
+    )
+    .execute(&mut faults)
+    .await
+    .unwrap();
+    assert!(
+        log.remove_session_family(
+            "revision",
+            revision,
+            maka_event_log::sessions::RemovalAuthority::Unmanaged
+        )
+        .await
+        .is_err()
+    );
+    assert!(log.session_retirement("root").await.unwrap().is_none());
+    assert!(
+        !log.get_session::<Value>("child")
+            .await
+            .unwrap()
+            .unwrap()
+            .archived
+    );
+    sqlx::query("DROP TRIGGER refuse_archive")
+        .execute(&mut faults)
+        .await
+        .unwrap();
+    faults.close().await.unwrap();
+    let RemoveFamilyResult::Accepted(plan) = log
+        .remove_session_family(
+            "revision",
+            revision,
+            maka_event_log::sessions::RemovalAuthority::Unmanaged,
+        )
+        .await
+        .unwrap()
+    else {
+        panic!("unchanged revision");
+    };
+    assert_eq!(plan.archived_subtask_count, 3);
+    assert!(plan.archive.contains(&"late".into()));
+    assert!(log.get_session::<Value>("root").await.unwrap().is_none());
+    assert!(log.get_session::<Value>("draft").await.unwrap().is_some());
+    assert!(log.get_session::<Value>("branch").await.unwrap().is_some());
+    assert!(
+        log.get_session::<Value>("child")
+            .await
+            .unwrap()
+            .unwrap()
+            .archived
+    );
+    assert!(log.retain_session("child").await.is_err());
+    assert!(matches!(
+        log.set_session_archived::<Value>("child", false, 7).await,
+        Err(StoreError::SessionBusy)
+    ));
+    log.close().await.unwrap();
+    let log = EventLog::open(&path).await.unwrap();
+    for session in &plan.remove {
+        assert_eq!(
+            log.finish_session_retirement(session).await.unwrap(),
+            SessionRetirement::Removed
+        );
+    }
+    for session in &plan.archive {
+        assert_eq!(
+            log.finish_session_retirement(session).await.unwrap(),
+            SessionRetirement::Archived
+        );
+    }
+    let receipt = log
+        .session_copy_receipt("child-revision")
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(copy(&log, receipt.request).await.id, "child-revision");
+    let archived = log.get_session::<Value>("child").await.unwrap().unwrap();
+    let branch = copy(
+        &log,
+        SessionCopy {
+            source_session_id: archived.id,
+            target_session_id: "from-archived".into(),
+            expected_source_revision: archived.revision,
+            purpose: CopyPurpose::EmptySideConversation,
+        },
+    )
+    .await;
+    assert_eq!(branch.id, "from-archived");
+    assert!(
+        log.retain_session("child").await.is_err(),
+        "copying history grants no execution"
+    );
+    log.set_session_archived::<Value>("child", false, 8)
+        .await
+        .unwrap();
+    turn(&log, "child", "restored-turn", false).await;
+    assert_eq!(
+        log.remove_session_family(
+            "revision",
+            revision,
+            maka_event_log::sessions::RemovalAuthority::Unmanaged
+        )
+        .await
+        .unwrap(),
+        RemoveFamilyResult::Accepted(plan)
+    );
+    assert!(
+        !log.get_session::<Value>("child")
+            .await
+            .unwrap()
+            .unwrap()
+            .archived
+    );
+    log.close().await.unwrap();
+}
+
 #[tokio::test]
 async fn copies_own_history_and_files_without_replaying_execution_across_retries_and_reopen() {
     let temp = tempfile::tempdir().unwrap();
@@ -236,20 +552,19 @@ async fn copies_own_history_and_files_without_replaying_execution_across_retries
         .await,
         Err(StoreError::SessionConflict)
     ));
-    // Remove source catalog state, not retained canonical facts. History reads
-    // and exact receipts must not rely on a live source Session.
-    let inspect = rusqlite::Connection::open(&path).unwrap();
-    inspect
-        .execute_batch(
-            "PRAGMA foreign_keys=ON;
-        BEGIN;
-        DELETE FROM artifact_catalog WHERE session_id='source';
-        DELETE FROM session_read_state WHERE session_id='source';
-        DELETE FROM session_control WHERE id='source';
-        COMMIT;",
-        )
-        .unwrap();
-    drop(inspect);
+    // Real removal releases source resources without removing another Session's
+    // retained evidence. Receipt recovery cannot depend on source catalog state.
+    let revision = log
+        .get_session::<Value>("source")
+        .await
+        .unwrap()
+        .unwrap()
+        .revision;
+    log.begin_session_removal("source", revision).await.unwrap();
+    assert_eq!(
+        log.finish_session_retirement("source").await.unwrap(),
+        maka_event_log::sessions::SessionRetirement::Removed
+    );
     log.close().await.unwrap();
     let log = EventLog::open(&path).await.unwrap();
     assert_eq!(
diff --git a/crates/fs-tools/src/worktree.rs b/crates/fs-tools/src/worktree.rs
index 4eaa256554..bc55167427 100644
--- a/crates/fs-tools/src/worktree.rs
+++ b/crates/fs-tools/src/worktree.rs
@@ -22,6 +22,7 @@
 mod checkout;
 mod ownership;
 mod patch;
+mod removal;
 
 use crate::workspace::{git, invalid};
 use serde::{Deserialize, Serialize};
@@ -132,6 +133,7 @@ impl Worktrees {
         self.validate(binding)?;
         interrupted(cancel)?;
         let _owner = ownership::lock(&self.root, binding)?;
+        removal::require_live(binding)?;
         if binding.directory.try_exists()? {
             return self.inspect(binding);
         }
@@ -142,6 +144,7 @@ impl Worktrees {
     pub fn inspect(&self, binding: &Binding) -> io::Result<()> {
         self.validate(binding)?;
         ownership::verify(&self.root.join(&binding.id), binding)?;
+        removal::require_live(binding)?;
         ownership::verify(&binding.admin(), binding)?;
         let repo = git::discover(&binding.directory)?;
         if repo
diff --git a/crates/fs-tools/src/worktree/ownership.rs b/crates/fs-tools/src/worktree/ownership.rs
index ff987f6401..a663dd2475 100644
--- a/crates/fs-tools/src/worktree/ownership.rs
+++ b/crates/fs-tools/src/worktree/ownership.rs
@@ -26,7 +26,7 @@ use std::{
     path::Path,
 };
 
-const OWNER: &str = "maka-owner.json";
+pub(super) const OWNER: &str = "maka-owner.json";
 
 pub(super) struct AllocationLock(File);
 impl Drop for AllocationLock {
diff --git a/crates/fs-tools/src/worktree/removal.rs b/crates/fs-tools/src/worktree/removal.rs
new file mode 100644
index 0000000000..5abe5f9a34
--- /dev/null
+++ b/crates/fs-tools/src/worktree/removal.rs
@@ -0,0 +1,132 @@
+/*
+ * 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 super::{Binding, Worktrees, ownership};
+use crate::workspace::invalid;
+use std::{fs, io, path::Path};
+
+const RETIRED: &str = "retired";
+
+impl Worktrees {
+    /// Permanently retire an allocation after all users and writers have drained.
+    /// Retry after interruption. Keep user branches and commits; the tiny owned
+    /// allocation tombstone prevents a stale binding from recreating the checkout.
+    pub fn remove(&self, binding: &Binding) -> io::Result<()> {
+        self.validate(binding)?;
+        let _owner = ownership::lock(&self.root, binding)?;
+        let allocation = binding.directory.parent().expect("validated layout");
+        let admin = binding.admin();
+        let retired = is_retired(binding)?;
+        if directory_exists(&admin)? {
+            // The owner is removed last. An empty directory is the only valid
+            // interruption point after unlinking it and before removing admin.
+            if !retired || fs::read_dir(&admin)?.next().is_some() {
+                ownership::verify(&admin, binding)?;
+            }
+        }
+        directory_exists(&binding.directory)?;
+        directory_exists(&allocation.join("checkout"))?;
+        if !retired {
+            ownership::write_new(&allocation.join(RETIRED), b"retired\n")?;
+            ownership::sync(allocation)?;
+        }
+        let repo = gix::open::Options::isolated()
+            .strict_config(true)
+            .open(&binding.common_dir)
+            .map_err(io::Error::other)?
+            .to_thread_local();
+        let base = gix::hash::ObjectId::from_hex(binding.base_commit.as_bytes())
+            .map_err(io::Error::other)?;
+        for name in [binding.base_ref(), binding.branch()] {
+            if let Some(reference) = repo.try_find_reference(&name).map_err(io::Error::other)? {
+                // Do not remove a branch the user advanced, or a replaced ref.
+                // delete() compares the observed target under the Git ref lock.
+                if reference.target() == gix::refs::TargetRef::Object(&base) {
+                    reference.delete().map_err(io::Error::other)?;
+                }
+            }
+        }
+        remove_directory(&binding.directory)?;
+        remove_directory(&allocation.join("checkout"))?;
+        remove_file(&allocation.join("checkout-ready"))?;
+        if directory_exists(&admin)? {
+            for entry in fs::read_dir(&admin)? {
+                let entry = entry?;
+                if entry.file_name() == ownership::OWNER {
+                    continue;
+                }
+                if entry.file_type()?.is_dir() {
+                    fs::remove_dir_all(entry.path())?;
+                } else {
+                    fs::remove_file(entry.path())?;
+                }
+            }
+            ownership::sync(&admin)?;
+            remove_file(&admin.join(ownership::OWNER))?;
+            fs::remove_dir(&admin)?;
+            ownership::sync(admin.parent().expect("worktree admin parent"))?;
+        }
+        ownership::sync(allocation)
+    }
+}
+
+pub(super) fn require_live(binding: &Binding) -> io::Result<()> {
+    if is_retired(binding)? {
+        Err(invalid("worktree allocation is retired"))
+    } else {
+        Ok(())
+    }
+}
+
+fn is_retired(binding: &Binding) -> io::Result<bool> {
+    let path = binding
+        .directory
+        .parent()
+        .expect("validated layout")
+        .join(RETIRED);
+    match fs::symlink_metadata(&path) {
+        Ok(meta) if meta.is_file() && fs::read(&path)? == b"retired\n" => Ok(true),
+        Ok(_) => Err(invalid("invalid worktree retirement marker")),
+        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
+        Err(error) => Err(error),
+    }
+}
+
+fn directory_exists(path: &Path) -> io::Result<bool> {
+    match fs::symlink_metadata(path) {
+        Ok(meta) if meta.is_dir() => Ok(true),
+        Ok(_) => Err(invalid("owned worktree directory was replaced")),
+        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
+        Err(error) => Err(error),
+    }
+}
+
+fn remove_directory(path: &Path) -> io::Result<()> {
+    if directory_exists(path)? {
+        fs::remove_dir_all(path)?;
+    }
+    Ok(())
+}
+
+fn remove_file(path: &Path) -> io::Result<()> {
+    match fs::remove_file(path) {
+        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
+        other => other,
+    }
+}
diff --git a/crates/fs-tools/tests/worktree.rs b/crates/fs-tools/tests/worktree.rs
index ecdc7d8388..c5cee4fcd2 100644
--- a/crates/fs-tools/tests/worktree.rs
+++ b/crates/fs-tools/tests/worktree.rs
@@ -205,6 +205,18 @@ fn linked_worktree_is_git_compatible_and_reopening_preserves_child_changes() {
             git(&source, &["status", "--porcelain"]).is_empty(),
             "export is reversible against its base"
         );
+        let child_commit = git(&source, &["rev-parse", "child-owned"]);
+        manager.remove(&binding).unwrap();
+        Worktrees::open(&root).unwrap().remove(&binding).unwrap();
+        assert!(!binding.directory().exists());
+        assert!(manager.ensure(&binding, &cancel).is_err());
+        assert_eq!(git(&source, &["rev-parse", "child-owned"]), child_commit);
+        assert!(
+            !git(&source, &["worktree", "list", "--porcelain"])
+                .contains(binding.directory().to_str().unwrap())
+        );
+        assert!(git(&source, &["for-each-ref", "refs/maka/", "refs/heads/maka/"]).is_empty());
+        assert!(git(&source, &["status", "--porcelain"]).is_empty());
     }
 }
 
@@ -249,6 +261,8 @@ fn worktree_recovery_rejects_dirty_sources_foreign_owners_and_missing_published_
     let bytes = fs::read(&owner).unwrap();
     fs::write(&owner, "{}").unwrap();
     assert!(manager.ensure(&binding, &cancel).is_err());
+    assert!(manager.remove(&binding).is_err());
+    assert!(binding.directory().exists());
     fs::write(&owner, bytes).unwrap();
     fs::rename(binding.directory(), allocation.join("user-recovered")).unwrap();
     assert!(
@@ -256,4 +270,50 @@ fn worktree_recovery_rejects_dirty_sources_foreign_owners_and_missing_published_
         "never reset a published but missing directory"
     );
     assert!(!binding.directory().exists());
+    manager.remove(&binding).unwrap();
+    assert_eq!(
+        fs::read_to_string(allocation.join("user-recovered/source.txt")).unwrap(),
+        "ready bytes preserved",
+        "cleanup only removes its known checkout paths"
+    );
+}
+
+#[test]
+fn interrupted_removal_preserves_advanced_branches_and_cannot_resurrect() {
+    let temp = tempfile::tempdir().unwrap();
+    let source = temp.path().join("repository");
+    repository(&source, "sha1");
+    let root = temp.path().join("host");
+    let manager = Worktrees::open(&root).unwrap();
+    let cancel = Arc::new(AtomicBool::new(false));
+    let id = "3".repeat(64);
+    let binding = manager.plan(&source, &id, cancel.clone()).unwrap();
+    manager.ensure(&binding, &cancel).unwrap();
+    fs::write(binding.directory().join("source.txt"), "committed\n").unwrap();
+    git(binding.directory(), &["commit", "-am", "keep this commit"]);
+    let branch = format!("refs/heads/maka/{id}");
+    let commit = git(&source, &["rev-parse", &branch]);
+    let allocation = binding.directory().parent().unwrap();
+    let admin = source.join(".git/worktrees").join(format!("maka-{id}"));
+    let owner = admin.join("maka-owner.json");
+    let proof = fs::read(&owner).unwrap();
+    fs::write(&owner, "{}").unwrap();
+    assert!(manager.remove(&binding).is_err());
+    assert!(!allocation.join("retired").exists());
+    assert!(binding.directory().exists());
+    fs::write(&owner, proof).unwrap();
+    // Crash after fencing and partially removing the checkout/admin payload.
+    fs::write(allocation.join("retired"), "retired\n").unwrap();
+    fs::remove_file(binding.directory().join("source.txt")).unwrap();
+    fs::remove_file(admin.join("index")).unwrap();
+    assert!(manager.ensure(&binding, &cancel).is_err());
+    manager.remove(&binding).unwrap();
+    assert_eq!(git(&source, &["rev-parse", &branch]), commit);
+    // Crash in the owner-unlink/rmdir gap leaves an empty admin directory.
+    fs::create_dir(&admin).unwrap();
+    Worktrees::open(&root).unwrap().remove(&binding).unwrap();
+    assert!(!admin.exists());
+    assert!(manager.ensure(&binding, &cancel).is_err());
+    assert_eq!(git(&source, &["rev-parse", &branch]), commit);
+    assert!(git(&source, &["status", "--porcelain"]).is_empty());
 }
diff --git a/crates/js-runtime/src/plugin/sdk.js b/crates/js-runtime/src/plugin/sdk.js
index 4c537b4066..1246a6f5fe 100644
--- a/crates/js-runtime/src/plugin/sdk.js
+++ b/crates/js-runtime/src/plugin/sdk.js
@@ -208,6 +208,9 @@
       },
       resume: (input) => call('resume', input),
       configure: (input) => call('configure', input),
+      removeSession: (input) => call('removeSession', input),
+      removalReceipt: (sessionId) => call('removalReceipt', { sessionId }),
+      previewRemoval: (sessionId) => call('previewRemoval', { sessionId }),
       input: (invocation) => call('input', invocation),
       readMessage: (input) => call('readMessage', input),
       enqueue: (input) => call('enqueue', input),
diff --git a/crates/plugins/src/execution.rs b/crates/plugins/src/execution.rs
index b1e90f27ac..b0ad5d48c5 100644
--- a/crates/plugins/src/execution.rs
+++ b/crates/plugins/src/execution.rs
@@ -29,8 +29,10 @@ pub use message::{
     Enqueue, Excerpt, MessageObservation, MessageReceipt, MessageResult, MessageState,
     SessionMessage,
 };
+mod removal;
 mod root;
 pub use interaction::{OfferInteraction, Prompt};
+pub use removal::{RemovalReceipt, RemoveSession, RemovedSession};
 pub use root::{CreateRoot, RootApproval, RootTemplate, Settings as RootSettings};
 
 /// Persisted constraints, not a bearer capability. Only an explicit Host grant
@@ -416,6 +418,23 @@ pub enum RevisionDisposition {
 
 /// An acquired execution capability retains its captured permission ceiling.
 pub trait Commands: Send + Sync + std::any::Any {
+    /// Remove an authorized revision family; every directly removed member must
+    /// be covered. Accepted work settles independently of the caller's lifetime.
+    fn remove_session(
+        &self,
+        request: RemoveSession,
+    ) -> futures_util::future::BoxFuture<'_, Result<RemovedSession, CommandError>>;
+    /// Read only an accepted removal. No catalog record or active execution is
+    /// required; current plugin identity and access are still checked.
+    fn removal_receipt(
+        &self,
+        session_id: String,
+    ) -> futures_util::future::BoxFuture<'_, Result<Option<RemovalReceipt>, CommandError>>;
+    /// Advisory count; removal recomputes and authorizes the plan at commit.
+    fn preview_removal(
+        &self,
+        session_id: String,
+    ) -> futures_util::future::BoxFuture<'_, Result<u64, CommandError>>;
     /// Copy an immutable attachment between two Host-issued capabilities. Both
     /// endpoints are reauthorized; a source Session ID alone grants nothing.
     fn copy_attachment(
diff --git a/crates/plugins/src/execution/removal.rs b/crates/plugins/src/execution/removal.rs
new file mode 100644
index 0000000000..674098c4c4
--- /dev/null
+++ b/crates/plugins/src/execution/removal.rs
@@ -0,0 +1,62 @@
+/*
+ * 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 serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct RemoveSession {
+    pub session_id: String,
+    pub expected_revision: u64,
+}
+
+impl RemoveSession {
+    pub fn validate(&self) -> Result<(), crate::Error> {
+        crate::name(&self.session_id)?;
+        if self.expected_revision == 0 || self.expected_revision >= 1 << 53 {
+            return Err(crate::Error::Invalid("invalid Session revision".into()));
+        }
+        Ok(())
+    }
+}
+
+/// The accepted plan is durable; filesystem cleanup may still be pending.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct RemovalReceipt {
+    pub session_id: String,
+    pub archived_subtask_count: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum RemovedSession {
+    Removed {
+        receipt: RemovalReceipt,
+    },
+    RevisionConflict {
+        expected_revision: u64,
+        actual_revision: u64,
+    },
+}
diff --git a/crates/protocol/src/authority.rs b/crates/protocol/src/authority.rs
index 4f70af7a4c..55e98ad69a 100644
--- a/crates/protocol/src/authority.rs
+++ b/crates/protocol/src/authority.rs
@@ -120,6 +120,7 @@ impl Operation {
                 | Self::SessionRecapGenerate
                 | Self::SessionRemove
                 | Self::SessionRemovePreview
+                | Self::SessionRemoveQuery
                 | Self::SessionRevisionAbandon
                 | Self::SessionRevisionCreate
                 | Self::SessionTranscriptPage
diff --git a/crates/protocol/src/operation.rs b/crates/protocol/src/operation.rs
index cbf700d2ca..6ca6a7283c 100644
--- a/crates/protocol/src/operation.rs
+++ b/crates/protocol/src/operation.rs
@@ -219,6 +219,7 @@ operations! {
     SessionRecapGenerate => ("session.recap.generate", Command, Ready),
     SessionRemove => ("session.remove", Command, Ready),
     SessionRemovePreview => ("session.remove.preview", Query, Ready),
+    SessionRemoveQuery => ("session.remove.query", Query, Ready),
     SessionRevisionAbandon => ("session.revision.abandon", Command, Ready),
     SessionRevisionCreate => ("session.revision.create", Command, Ready),
     SessionSharedQuery => ("session.shared.query", Query, Ready),
diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs
index eedbf3052a..6636143617 100644
--- a/crates/protocol/src/session.rs
+++ b/crates/protocol/src/session.rs
@@ -132,6 +132,25 @@ pub struct SessionRemovePreviewInput {
 }
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct SessionRemoveQueryInput {
+    pub session_id: String,
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum SessionRemoveQueryResult {
+    Missing,
+    Removed {
+        session_id: String,
+        archived_subtask_count: u64,
+    },
+}
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
 pub struct SessionRemovePreviewResult {
     pub archivable_subtask_count: u64,
 }
@@ -190,6 +209,8 @@ decoder!(
 decoder!(decode_session_lifecycle_set_input, SessionLifecycleSetInput);
 decoder!(decode_session_remove_input, SessionRemoveInput);
 decoder!(decode_session_remove_result, SessionRemoveResult);
+decoder!(decode_session_remove_query_input, SessionRemoveQueryInput);
+decoder!(decode_session_remove_query_result, SessionRemoveQueryResult);
 decoder!(
     decode_session_remove_preview_input,
     SessionRemovePreviewInput
diff --git a/crates/runtime-host/src/execution.rs b/crates/runtime-host/src/execution.rs
index 34af02224d..1074d0ce4d 100644
--- a/crates/runtime-host/src/execution.rs
+++ b/crates/runtime-host/src/execution.rs
@@ -32,9 +32,11 @@ mod plugins;
 pub(crate) use plugins::ResourceTarget;
 pub(crate) mod permissions;
 mod prepare;
+mod processes;
 mod provider;
 mod read;
 mod recovery;
+mod removal;
 mod resume;
 mod shell;
 pub(crate) mod snapshot;
@@ -78,9 +80,10 @@ pub(crate) struct Executions {
     interactions: Arc<crate::server::interactions::Interactions>,
     active: Mutex<HashMap<String, ActiveRun>>,
     submissions: message::Submissions,
-    plugin_processes: Arc<Mutex<HashMap<String, usize>>>,
+    plugin_processes: processes::Registry,
     workers: TaskTracker,
     handoff_wake: tokio::sync::Notify,
+    removal_wake: tokio::sync::Notify,
     shutdown: CancellationToken,
 }
 
@@ -140,9 +143,10 @@ impl Executions {
             interactions,
             writes: Arc::new(maka_fs_tools::WriteCoordinator::default()),
             active: Mutex::new(HashMap::new()),
-            plugin_processes: Arc::default(),
+            plugin_processes: Default::default(),
             workers,
             handoff_wake: tokio::sync::Notify::new(),
+            removal_wake: tokio::sync::Notify::new(),
             shutdown,
         })
     }
@@ -293,8 +297,10 @@ impl Executions {
 fn requires_drain(error: &RunError) -> bool {
     matches!(
         error,
-        RunError::Commit(_)
-            | RunError::Store(StoreError::CommitUnknown(_) | StoreError::OperationUnknown)
+        RunError::Commit(
+            maka_runtime::event::CommitError::Rejected(_)
+                | maka_runtime::event::CommitError::OutcomeUnknown(_)
+        ) | RunError::Store(StoreError::CommitUnknown(_) | StoreError::OperationUnknown)
             | RunError::Tool(ToolError::Persistence(_) | ToolError::CleanupUnconfirmed(_))
     )
 }
@@ -307,6 +313,7 @@ fn execution_error(error: RunError) -> OperationError {
             Code::OutcomeUnknown
         }
         RunError::Busy => Code::SessionBusy,
+        RunError::Commit(maka_runtime::event::CommitError::Retired) => Code::NotFound,
         RunError::ReconciliationRequired(_) => Code::OperationUnavailable,
         RunError::InvalidInput(_) => Code::OperationUnavailable,
         _ => Code::InternalFailure,
diff --git a/crates/runtime-host/src/execution/control.rs b/crates/runtime-host/src/execution/control.rs
index fa4d877140..891c8c01dc 100644
--- a/crates/runtime-host/src/execution/control.rs
+++ b/crates/runtime-host/src/execution/control.rs
@@ -22,6 +22,62 @@ use maka_protocol::OperationErrorCode as Code;
 use maka_runtime::event::Invocation;
 
 impl Executions {
+    /// The durable fence survives a lost requester. Never hold global admission
+    /// while waiting for another Session's resources to release native handles.
+    pub(crate) async fn drain_retiring_session(&self, session: &str) -> Result<bool> {
+        let admission = self.lock_admission().await;
+        if self
+            .log
+            .session_retirement(session)
+            .await
+            .map_err(internal)?
+            .is_none()
+        {
+            return Err(failure(
+                Code::OperationConflict,
+                "Session has no removal intent",
+            ));
+        }
+        let runs: Vec<_> = self
+            .active
+            .lock()
+            .unwrap()
+            .values()
+            .filter(|run| run.invocation.session_id == session)
+            .cloned()
+            .collect();
+        let mut completed = self.plugin_processes.stop(session);
+        for run in &runs {
+            run.cancellation.cancel();
+            completed.push(run.completed.clone());
+        }
+        for run in &runs {
+            self.interactions.stop_run(&run.invocation).await?;
+        }
+        drop(admission);
+        let drain = async {
+            self.shells.stop_session(session).await.map_err(|error| {
+                self.begin_drain();
+                internal(error)
+            })?;
+            for completed in completed {
+                completed.cancelled().await;
+            }
+            Ok::<(), maka_protocol::OperationError>(())
+        };
+        match tokio::time::timeout(std::time::Duration::from_secs(30), drain).await {
+            Err(_) => return Ok(false),
+            Ok(result) => result?,
+        }
+        if self.shutdown.is_cancelled() {
+            return Err(failure(
+                Code::HostDraining,
+                "Resource cleanup requires Host recovery",
+            ));
+        }
+        Ok(true)
+    }
+
     pub(crate) fn active_session_owner(&self, session: &str) -> Option<Invocation> {
         self.active
             .lock()
diff --git a/crates/runtime-host/src/execution/plugins.rs b/crates/runtime-host/src/execution/plugins.rs
index 6cc1f6e15f..6df4106a4d 100644
--- a/crates/runtime-host/src/execution/plugins.rs
+++ b/crates/runtime-host/src/execution/plugins.rs
@@ -30,6 +30,7 @@ mod interactions;
 mod llm;
 mod messages;
 mod network;
+mod removal;
 mod resume;
 mod root;
 mod submit;
@@ -95,15 +96,6 @@ pub(crate) struct ProcessAdmission {
 }
 
 impl Executions {
-    pub(crate) async fn retain_plugin_process_session(
-        &self,
-        session: &str,
-    ) -> Result<(), StoreError> {
-        self.log.retain_session(session).await?;
-        self.publish_session_change(session).await;
-        Ok(())
-    }
-
     pub(crate) async fn admit_plugin_process(
         &self,
         scope: &maka_plugins::call::Scope,
@@ -346,6 +338,28 @@ impl BoundCommands {
     }
 
     async fn authorize(&self, host: &Executions, session: &str) -> Result<(), Error> {
+        let current = self.authorize_session_control(host, session).await?;
+        if host
+            .log
+            .session_retirement(session)
+            .await
+            .map_err(storage)?
+            .is_some()
+        {
+            return Err(Error::Revoked);
+        }
+        if current.archived {
+            return Err(Error::Denied);
+        }
+        Ok(())
+    }
+
+    /// Lifecycle control may inspect archived Sessions, but grants no execution.
+    async fn authorize_session_control(
+        &self,
+        host: &Executions,
+        session: &str,
+    ) -> Result<maka_event_log::sessions::SessionRecord<SessionConfiguration>, Error> {
         self.authorize_origin(host).await?;
         if host
             .log
@@ -369,8 +383,7 @@ impl BoundCommands {
             .await
             .map_err(storage)?
             .ok_or(Error::NotFound)?;
-        if current.archived
-            || current.configuration.boundary_revision != grant.boundary_revision
+        if current.configuration.boundary_revision != grant.boundary_revision
             || current.configuration.sandbox_mode != grant.sandbox_mode
             || current.configuration.approval_policy != grant.approval_policy
             || current.configuration.workspace.host_cwd != grant.cwd
@@ -378,7 +391,7 @@ impl BoundCommands {
         {
             return Err(Error::Denied);
         }
-        Ok(())
+        Ok(current)
     }
 
     async fn receipt(&self, host: &Executions, operation: &str) -> Result<Receipt, Error> {
@@ -394,6 +407,21 @@ impl BoundCommands {
 }
 
 impl Commands for BoundCommands {
+    fn remove_session(
+        &self,
+        request: maka_plugins::execution::RemoveSession,
+    ) -> BoxFuture<'_, Result<maka_plugins::execution::RemovedSession, Error>> {
+        Box::pin(self.remove(request))
+    }
+    fn removal_receipt(
+        &self,
+        session_id: String,
+    ) -> BoxFuture<'_, Result<Option<maka_plugins::execution::RemovalReceipt>, Error>> {
+        Box::pin(self.read_removal_receipt(session_id))
+    }
+    fn preview_removal(&self, session_id: String) -> BoxFuture<'_, Result<u64, Error>> {
+        Box::pin(self.preview_session_removal(session_id))
+    }
     fn copy_attachment(
         &self,
         source: Arc<dyn Commands>,
@@ -864,6 +892,7 @@ fn storage(error: StoreError) -> Error {
         StoreError::EventConflict | StoreError::SessionConflict => Error::Conflict,
         StoreError::SessionBusy => Error::Busy,
         StoreError::SessionNotFound => Error::NotFound,
+        StoreError::SessionRetired => Error::Revoked,
         StoreError::CommitUnknown(_) | StoreError::OperationUnknown => {
             Error::OutcomeUnknown(error.to_string())
         }
diff --git a/crates/runtime-host/src/execution/plugins/children.rs b/crates/runtime-host/src/execution/plugins/children.rs
index 2ebe0dd2d1..e205d70c00 100644
--- a/crates/runtime-host/src/execution/plugins/children.rs
+++ b/crates/runtime-host/src/execution/plugins/children.rs
@@ -132,6 +132,7 @@ impl BoundCommands {
         let grants = self.grants.clone();
         let submission_stop = self.submission_stop.clone();
         let worker = host.clone();
+        let creator = self.namespace.clone();
         let (send, receive) = tokio::sync::oneshot::channel();
         host.workers.spawn(async move {
             let result = async {
@@ -225,6 +226,9 @@ impl BoundCommands {
                         child.boundary_revision = 0;
                         child.collaboration_mode = CollaborationMode::Agent;
                         child.orchestration_mode = BehaviorId::default();
+                        worker
+                            .validate_workspace(&child)
+                            .map_err(|error| Error::Invalid(error.message))?;
                         let now = std::time::SystemTime::now()
                             .duration_since(std::time::UNIX_EPOCH)
                             .map_err(|e| Error::Host(e.to_string()))?
@@ -233,7 +237,17 @@ impl BoundCommands {
                             .map_err(|_| Error::Host("system clock overflow".into()))?;
                         worker
                             .log
-                            .create_session(&id, &fingerprint, &child, now)
+                            .create_plugin_session(
+                                &maka_event_log::sessions::PluginSession {
+                                    session_id: id.clone(),
+                                    creator,
+                                    fingerprint: fingerprint.clone(),
+                                    managed: false,
+                                    authority_session_id: Some(request.parent_session_id.clone()),
+                                },
+                                &child,
+                                now,
+                            )
                             .await
                             .map_err(storage)?
                     }
diff --git a/crates/runtime-host/src/execution/plugins/removal.rs b/crates/runtime-host/src/execution/plugins/removal.rs
new file mode 100644
index 0000000000..d2217e09df
--- /dev/null
+++ b/crates/runtime-host/src/execution/plugins/removal.rs
@@ -0,0 +1,158 @@
+/*
+ * 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 super::{BoundCommands, Error, Executions, protocol, storage};
+use maka_event_log::sessions::{RemovalAuthority, RemovalPlan, RemoveFamilyResult};
+use maka_plugins::execution::{RemovalReceipt, RemoveSession, RemovedSession};
+
+impl BoundCommands {
+    pub(super) async fn read_removal_receipt(
+        &self,
+        session: String,
+    ) -> Result<Option<RemovalReceipt>, Error> {
+        let host = self.executions()?;
+        let _lease = self.context.admit().map_err(|_| Error::Revoked)?;
+        self.authorize_removal_receipt(&host, &session).await?;
+        Ok(host
+            .log
+            .session_removal_receipt(&session)
+            .await
+            .map_err(storage)?
+            .map(|plan| receipt(session, plan)))
+    }
+
+    async fn authorize_removal_receipt(
+        &self,
+        host: &Executions,
+        session: &str,
+    ) -> Result<(), Error> {
+        self.authorize_origin(host).await?;
+        if self.submission_stop.is_cancelled() {
+            return Err(Error::Revoked);
+        }
+        if host
+            .log
+            .session_manager(session)
+            .await
+            .map_err(storage)?
+            .is_some_and(|manager| manager != self.namespace)
+        {
+            return Err(Error::Denied);
+        }
+        if !self.grants.lock().unwrap().contains_key(session)
+            && host
+                .log
+                .session_creator(session)
+                .await
+                .map_err(storage)?
+                .as_ref()
+                != Some(&self.namespace)
+        {
+            return Err(Error::Denied);
+        }
+        Ok(())
+    }
+
+    async fn authorize_removal_plan(
+        &self,
+        host: &Executions,
+        session: &str,
+    ) -> Result<RemovalPlan, Error> {
+        self.authorize_session_control(host, session).await?;
+        let plan = host
+            .log
+            .preview_session_removal(session)
+            .await
+            .map_err(storage)?;
+        for target in &plan.remove {
+            self.authorize_session_control(host, target).await?;
+        }
+        if self.submission_stop.is_cancelled() {
+            return Err(Error::Revoked);
+        }
+        Ok(plan)
+    }
+
+    pub(super) async fn preview_session_removal(&self, session: String) -> Result<u64, Error> {
+        let host = self.executions()?;
+        let _lease = self.context.admit().map_err(|_| Error::Revoked)?;
+        let _gate = host.interactions.own_admission().await;
+        Ok(self
+            .authorize_removal_plan(&host, &session)
+            .await?
+            .archived_subtask_count)
+    }
+
+    pub(super) async fn remove(&self, input: RemoveSession) -> Result<RemovedSession, Error> {
+        input
+            .validate()
+            .map_err(|error| Error::Invalid(error.to_string()))?;
+        let host = self.executions()?;
+        let _lease = self.context.admit().map_err(|_| Error::Revoked)?;
+        let gate = host.interactions.own_admission().await;
+        self.authorize_removal_receipt(&host, &input.session_id)
+            .await?;
+        if let Some(plan) = host
+            .log
+            .session_removal_receipt(&input.session_id)
+            .await
+            .map_err(storage)?
+        {
+            return Ok(RemovedSession::Removed {
+                receipt: receipt(input.session_id, plan),
+            });
+        }
+        let plan = self
+            .authorize_removal_plan(&host, &input.session_id)
+            .await?;
+        let authority = RemovalAuthority::Granted {
+            namespace: self.namespace.clone(),
+            sessions: plan.remove.into_iter().collect(),
+        };
+        Ok(
+            match host
+                .remove_family(
+                    input.session_id.clone(),
+                    input.expected_revision,
+                    authority,
+                    gate,
+                )
+                .await
+                .map_err(protocol)?
+            {
+                RemoveFamilyResult::Accepted(plan) => RemovedSession::Removed {
+                    receipt: receipt(input.session_id, plan),
+                },
+                RemoveFamilyResult::RevisionConflict { expected, actual } => {
+                    RemovedSession::RevisionConflict {
+                        expected_revision: expected,
+                        actual_revision: actual,
+                    }
+                }
+            },
+        )
+    }
+}
+
+fn receipt(session_id: String, plan: RemovalPlan) -> RemovalReceipt {
+    RemovalReceipt {
+        session_id,
+        archived_subtask_count: plan.archived_subtask_count,
+    }
+}
diff --git a/crates/runtime-host/src/execution/plugins/root.rs b/crates/runtime-host/src/execution/plugins/root.rs
index ee4b22458a..d37f9413f3 100644
--- a/crates/runtime-host/src/execution/plugins/root.rs
+++ b/crates/runtime-host/src/execution/plugins/root.rs
@@ -148,8 +148,7 @@ impl BoundCommands {
             return Err(Error::Denied);
         }
         let current = &record.configuration;
-        if record.archived
-            || current.workspace != approval.workspace
+        if current.workspace != approval.workspace
             || current.workspace_origin != approval.workspace_origin
             || rank(current.sandbox_mode) > rank(approval.sandbox_mode)
             || !current
@@ -422,6 +421,7 @@ impl BoundCommands {
                 }
                 let expected =
                     configuration(&worker, &id, &request, &approval, existing.is_none()).await?;
+                worker.validate_workspace(&expected).map_err(|error| Error::Invalid(error.message))?;
                 let record = match existing {
                     Some(record) => record,
                     None => {
diff --git a/crates/runtime-host/src/execution/plugins/workspace.rs b/crates/runtime-host/src/execution/plugins/workspace.rs
index 6b8ce3c587..13ba82c2dc 100644
--- a/crates/runtime-host/src/execution/plugins/workspace.rs
+++ b/crates/runtime-host/src/execution/plugins/workspace.rs
@@ -155,7 +155,7 @@ async fn quiescent(
         }
         let session = &member.session_id;
         if host.has_session_work(session).await.map_err(storage)?
-            || host.plugin_processes.lock().unwrap().contains_key(session)
+            || host.plugin_processes.has(session)
             || host
                 .log
                 .has_unsettled_shells(session)
diff --git a/crates/runtime-host/src/execution/processes.rs b/crates/runtime-host/src/execution/processes.rs
new file mode 100644
index 0000000000..374e947603
--- /dev/null
+++ b/crates/runtime-host/src/execution/processes.rs
@@ -0,0 +1,130 @@
+/*
+ * 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 std::{
+    collections::HashMap,
+    sync::{Arc, Mutex},
+};
+use tokio_util::sync::CancellationToken;
+use uuid::Uuid;
+
+#[derive(Default, Clone)]
+pub(super) struct Registry(Arc<Mutex<HashMap<String, HashMap<Uuid, Control>>>>);
+
+struct Control {
+    stop: CancellationToken,
+    completed: CancellationToken,
+}
+
+impl Registry {
+    pub(super) fn has(&self, session: &str) -> bool {
+        self.0.lock().unwrap().contains_key(session)
+    }
+
+    /// The caller fences admission before requesting stop. Completion means
+    /// the resource owner has released native handles, not merely cancelled them.
+    pub(super) fn stop(&self, session: &str) -> Vec<CancellationToken> {
+        self.0
+            .lock()
+            .unwrap()
+            .get(session)
+            .into_iter()
+            .flat_map(|entries| entries.values())
+            .map(|entry| {
+                entry.stop.cancel();
+                entry.completed.clone()
+            })
+            .collect()
+    }
+}
+
+impl super::Executions {
+    pub(crate) fn own_plugin_process(
+        &self,
+        session: &str,
+        stop: CancellationToken,
+    ) -> ProcessActivity {
+        let id = Uuid::new_v4();
+        let completed = CancellationToken::new();
+        self.plugin_processes
+            .0
+            .lock()
+            .unwrap()
+            .entry(session.into())
+            .or_default()
+            .insert(
+                id,
+                Control {
+                    stop,
+                    completed: completed.clone(),
+                },
+            );
+        ProcessActivity {
+            log: self.log.clone(),
+            registry: self.plugin_processes.clone(),
+            session: session.into(),
+            id,
+            completed,
+            shutdown: self.shutdown.clone(),
+            cleanup_confirmed: true,
+        }
+    }
+}
+
+/// Instance-lifetime processes outlive Turns but retain Session resource ownership.
+pub(crate) struct ProcessActivity {
+    log: Arc<maka_event_log::EventLog>,
+    registry: Registry,
+    session: String,
+    id: Uuid,
+    completed: CancellationToken,
+    shutdown: CancellationToken,
+    cleanup_confirmed: bool,
+}
+
+impl ProcessActivity {
+    pub(crate) async fn start(&mut self) -> Result<(), maka_event_log::StoreError> {
+        self.cleanup_confirmed = false;
+        self.log.admit_session_process(&self.session, self.id).await
+    }
+    pub(crate) async fn settle(mut self, clean: bool) -> Result<(), maka_event_log::StoreError> {
+        if clean {
+            self.log.clean_session_process(self.id).await?;
+        }
+        self.cleanup_confirmed = clean;
+        Ok(())
+    }
+}
+
+impl Drop for ProcessActivity {
+    fn drop(&mut self) {
+        // A dropped worker is not evidence of cleanup. Before start, however,
+        // publication failure cannot have created an OS resource.
+        if !self.cleanup_confirmed {
+            self.shutdown.cancel();
+        }
+        let mut registry = self.registry.0.lock().unwrap();
+        let entries = registry.get_mut(&self.session).expect("registered process");
+        entries.remove(&self.id);
+        if entries.is_empty() {
+            registry.remove(&self.session);
+        }
+        self.completed.cancel();
+    }
+}
diff --git a/crates/runtime-host/src/execution/removal.rs b/crates/runtime-host/src/execution/removal.rs
new file mode 100644
index 0000000000..941d45f787
--- /dev/null
+++ b/crates/runtime-host/src/execution/removal.rs
@@ -0,0 +1,233 @@
+/*
+ * 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 super::{Executions, Result, internal};
+use crate::session::SessionConfiguration;
+use maka_event_log::sessions::SessionRetirement;
+use std::{sync::Arc, time::Duration};
+
+impl Executions {
+    /// The accepted worker owns commit, notifications and recovery scheduling,
+    /// even if the Remote or plugin stops waiting for its receipt.
+    pub(crate) async fn remove_family(
+        self: &Arc<Self>,
+        session: String,
+        expected: u64,
+        authority: maka_event_log::sessions::RemovalAuthority,
+        admission: tokio::sync::OwnedMutexGuard<()>,
+    ) -> Result<maka_event_log::sessions::RemoveFamilyResult> {
+        let worker = self.clone();
+        let (send, receive) = tokio::sync::oneshot::channel();
+        self.workers.spawn(async move {
+            let result = if worker.accepting() {
+                worker
+                    .log
+                    .remove_session_family(&session, expected, authority)
+                    .await
+                    .map_err(|error| {
+                        if matches!(
+                            error,
+                            maka_event_log::StoreError::CommitUnknown(_)
+                                | maka_event_log::StoreError::OperationUnknown
+                        ) {
+                            worker.begin_drain();
+                        }
+                        if matches!(error, maka_event_log::StoreError::PrefixTooLarge) {
+                            super::failure(
+                                maka_protocol::OperationErrorCode::OperationConflict,
+                                "Session removal exceeds the family size limit",
+                            )
+                        } else {
+                            use maka_event_log::StoreError;
+                            use maka_protocol::OperationErrorCode as Code;
+                            let code = match error {
+                                StoreError::SessionConflict | StoreError::SessionRetired => {
+                                    Code::OperationConflict
+                                }
+                                StoreError::SessionNotFound => Code::NotFound,
+                                StoreError::SessionBusy => Code::SessionBusy,
+                                StoreError::CommitUnknown(_) | StoreError::OperationUnknown => {
+                                    Code::CommitOutcomeUnknown
+                                }
+                                _ => Code::PersistenceFailed,
+                            };
+                            super::failure(code, &error.to_string())
+                        }
+                    })
+            } else {
+                Err(super::failure(
+                    maka_protocol::OperationErrorCode::HostDraining,
+                    "Host is draining",
+                ))
+            };
+            if let Ok(maka_event_log::sessions::RemoveFamilyResult::Accepted(plan)) = &result {
+                for session in plan.remove.iter().chain(&plan.archive) {
+                    // A receipt retry must not retire a dependent that the user
+                    // restored after the original removal finished.
+                    match worker.log.session_retirement(session).await {
+                        Ok(Some(_)) => worker
+                            .capabilities
+                            .registry
+                            .lock()
+                            .unwrap_or_else(|error| error.into_inner())
+                            .release_session(session),
+                        Ok(None) => {}
+                        Err(_) => worker.begin_drain(),
+                    }
+                }
+            }
+            drop(admission);
+            if let Ok(maka_event_log::sessions::RemoveFamilyResult::Accepted(plan)) = &result {
+                worker.request_removal_recovery();
+                for session in plan.remove.iter().chain(&plan.archive) {
+                    worker.publish_session_change(session).await;
+                }
+            }
+            let _ = send.send(result);
+        });
+        receive.await.map_err(|_| {
+            super::failure(
+                maka_protocol::OperationErrorCode::CommitOutcomeUnknown,
+                "Session removal owner disappeared",
+            )
+        })?
+    }
+
+    pub(crate) fn request_removal_recovery(&self) {
+        self.removal_wake.notify_one();
+    }
+
+    pub(crate) fn start_removal_recovery(self: &Arc<Self>) {
+        let host = self.clone();
+        self.workers.spawn(async move {
+            let mut pending = false;
+            let mut delay = Duration::from_millis(250);
+            loop {
+                tokio::select! {
+                    biased;
+                    _ = host.shutdown.cancelled() => break,
+                    _ = host.removal_wake.notified() => { delay = Duration::from_millis(250); }
+                    _ = tokio::time::sleep(delay), if pending => {
+                        delay = (delay * 2).min(Duration::from_secs(30));
+                    }
+                }
+                match host.recover_removals().await {
+                    Ok(remaining) => pending = remaining,
+                    Err(error) => {
+                        host.begin_drain();
+                        eprintln!("Session removal recovery failed: {}", error.message);
+                        break;
+                    }
+                }
+            }
+        });
+        self.request_removal_recovery();
+    }
+
+    async fn recover_removals(&self) -> Result<bool> {
+        let mut after = None;
+        let mut pending = false;
+        loop {
+            let page = self
+                .log
+                .pending_session_retirements(after.as_deref())
+                .await
+                .map_err(internal)?;
+            if page.is_empty() {
+                return Ok(pending);
+            }
+            for session in page {
+                after = Some(session.clone());
+                if !self.accepting() {
+                    return Ok(true);
+                }
+                if !self.drain_retiring_session(&session).await? {
+                    pending = true;
+                    continue;
+                }
+                self.capabilities
+                    .registry
+                    .lock()
+                    .unwrap_or_else(|error| error.into_inner())
+                    .release_session(&session);
+                pending |= !self.clean_removed_session(&session).await?;
+            }
+        }
+    }
+
+    async fn clean_removed_session(&self, session: &str) -> Result<bool> {
+        let admission = self.lock_admission().await;
+        if !self
+            .log
+            .session_retirement_ready(session)
+            .await
+            .map_err(internal)?
+        {
+            return Ok(false);
+        }
+        let Some(record) = self
+            .log
+            .retiring_session::<SessionConfiguration>(session)
+            .await
+            .map_err(internal)?
+        else {
+            return Ok(true);
+        };
+        let retirement = self
+            .log
+            .session_retirement(session)
+            .await
+            .map_err(internal)?;
+        if retirement == Some(SessionRetirement::Removing)
+            && let Some(binding) = record.configuration.worktree
+        {
+            let shared = self
+                .log
+                .workspace_has_other_session(&record.configuration.workspace.host_cwd, session)
+                .await
+                .map_err(internal)?;
+            if !shared {
+                // New owners need a live parent and a binding; path-only roots
+                // cannot adopt this allocation. The durable fence has retired
+                // the last parent, so filesystem I/O needs no global gate.
+                drop(admission);
+                let root = self.paths.state_root.join("subagent-worktrees");
+                let cleanup = tokio::task::spawn_blocking(move || {
+                    maka_fs_tools::worktree::Worktrees::open(&root)?.remove(&binding)
+                })
+                .await
+                .map_err(internal)?;
+                if let Err(error) = cleanup {
+                    eprintln!("Session {session} workspace cleanup pending: {error}");
+                    return Ok(false);
+                }
+            }
+        }
+        let retired = !self
+            .log
+            .finish_session_retirement(session)
+            .await
+            .map_err(internal)?
+            .pending();
+        if retired {
+            self.publish_session_change(session).await;
+        }
+        Ok(retired)
+    }
+}
diff --git a/crates/runtime-host/src/execution/workspaces.rs b/crates/runtime-host/src/execution/workspaces.rs
index acaf503340..0474357f43 100644
--- a/crates/runtime-host/src/execution/workspaces.rs
+++ b/crates/runtime-host/src/execution/workspaces.rs
@@ -31,22 +31,31 @@ use std::{
 use tokio_util::sync::CancellationToken;
 
 impl Executions {
-    pub(crate) fn own_plugin_process(&self, session: &str) -> ProcessActivity {
-        *self
-            .plugin_processes
-            .lock()
-            .unwrap()
-            .entry(session.into())
-            .or_default() += 1;
-        ProcessActivity {
-            registry: self.plugin_processes.clone(),
-            session: session.into(),
+    /// Managed checkouts cannot acquire an untracked owner through a path-only
+    /// selection. Shared children inherit the binding and its lifecycle instead.
+    pub(crate) fn validate_workspace(&self, session: &SessionConfiguration) -> super::Result<()> {
+        let root = self.paths.state_root.join("subagent-worktrees");
+        let root = maka_fs_tools::workspace::project::host_path(&root).map_err(internal)?;
+        let cwd = std::path::Path::new(&session.workspace.host_cwd);
+        if session
+            .worktree
+            .as_ref()
+            .is_some_and(|binding| binding.directory() != cwd)
+            || (session.worktree.is_none() && cwd.starts_with(root))
+        {
+            return Err(failure(
+                Code::OperationConflict,
+                "Managed workspaces require their owning Session binding",
+            ));
         }
+        Ok(())
     }
+
     pub(super) async fn prepare_worktree(
         &self,
         session: &SessionConfiguration,
     ) -> super::Result<()> {
+        self.validate_workspace(session)?;
         let Some(binding) = session.worktree.clone() else {
             return Ok(());
         };
@@ -72,22 +81,6 @@ impl Executions {
     }
 }
 
-/// Instance-lifetime plugin processes outlive Turns but still write their Session workspace.
-pub(crate) struct ProcessActivity {
-    registry: Arc<std::sync::Mutex<std::collections::HashMap<String, usize>>>,
-    session: String,
-}
-impl Drop for ProcessActivity {
-    fn drop(&mut self) {
-        let mut registry = self.registry.lock().unwrap();
-        let count = registry.get_mut(&self.session).expect("registered process");
-        *count -= 1;
-        if *count == 0 {
-            registry.remove(&self.session);
-        }
-    }
-}
-
 /// Cancellation also propagates when the preparing future itself is dropped.
 struct Interrupt(Arc<AtomicBool>);
 impl Drop for Interrupt {
diff --git a/crates/runtime-host/src/plugins/javascript/bridge.rs b/crates/runtime-host/src/plugins/javascript/bridge.rs
index b089a7bfdc..83eff2fd50 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge.rs
@@ -596,6 +596,18 @@ impl State {
                 let (commands, input) = self.execution(input)?;
                 encode(commands.configure(input).await?)
             }
+            Request::RemoveSession(input) => {
+                let (commands, input) = self.execution(input)?;
+                encode(commands.remove_session(input).await?)
+            }
+            Request::RemovalReceipt(input) => {
+                let (commands, input) = self.execution(input)?;
+                encode(commands.removal_receipt(input.session_id).await?)
+            }
+            Request::PreviewRemoval(input) => {
+                let (commands, input) = self.execution(input)?;
+                encode(commands.preview_removal(input.session_id).await?)
+            }
             Request::ReadMessage(input) => {
                 let (commands, input) = self.execution(input)?;
                 encode(commands.read_message(input).await?)
diff --git a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
index ec1049343a..10563a4ba4 100644
--- a/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
+++ b/crates/runtime-host/src/plugins/javascript/bridge/wire.rs
@@ -151,6 +151,12 @@ pub(super) enum Request {
     ResumeExecution(Execution<maka_plugins::execution::Resume>),
     #[serde(rename = "execution.configure")]
     ConfigureExecution(Execution<maka_plugins::execution::Configure>),
+    #[serde(rename = "execution.removeSession")]
+    RemoveSession(Execution<maka_plugins::execution::RemoveSession>),
+    #[serde(rename = "execution.removalReceipt")]
+    RemovalReceipt(Execution<SessionQuery>),
+    #[serde(rename = "execution.previewRemoval")]
+    PreviewRemoval(Execution<SessionQuery>),
     #[serde(rename = "execution.readMessage")]
     ReadMessage(Execution<maka_plugins::execution::SessionMessage>),
     #[serde(rename = "execution.enqueue")]
diff --git a/crates/runtime-host/src/plugins/process.rs b/crates/runtime-host/src/plugins/process.rs
index 2e6e70a78d..d916e2effa 100644
--- a/crates/runtime-host/src/plugins/process.rs
+++ b/crates/runtime-host/src/plugins/process.rs
@@ -124,13 +124,13 @@ impl Processes {
         let activity = handle
             .target
             .session()
-            .map(|id| host.own_plugin_process(id));
+            .map(|id| host.own_plugin_process(id, stop.clone()));
         let session = handle.target.session().map(str::to_owned);
         let execution =
             self.0
                 .owner
                 .spawn_resource("protocol process", move |retiring| async move {
-                    let _activity = activity;
+                    let mut activity = activity;
                     let mut ticket = ticket;
                     if let Some(ticket) = &mut ticket {
                         ticket.start();
@@ -141,10 +141,11 @@ impl Processes {
                         Lifetime::Instance => CancellationToken::new(),
                     };
                     let result = async {
-                        if let Some(session) = session {
-                            host.retain_plugin_process_session(&session)
-                                .await
-                                .map_err(|error| error.to_string())?;
+                        if let Some(activity) = &mut activity {
+                            activity.start().await.map_err(|error| error.to_string())?;
+                        }
+                        if let Some(session) = &session {
+                            host.publish_session_change(session).await;
                         }
                         worker::run(
                             command,
@@ -162,6 +163,14 @@ impl Processes {
                         .await
                     }
                     .await;
+                    let result = match activity {
+                        Some(activity) => activity
+                            .settle(result.is_ok())
+                            .await
+                            .map_err(|error| error.to_string())
+                            .and(result),
+                        None => result,
+                    };
                     if result.is_err() {
                         host.begin_drain();
                     }
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index aa5803c6c4..c7fe22d938 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -371,6 +371,7 @@ impl Host {
         }
         startup_guard.disarm();
         host.executions.start_handoff_recovery(host.epoch.clone());
+        host.executions.start_removal_recovery();
         host.record_diagnostic(format_args!("Host ready: epoch {}", host.epoch));
         Ok(host)
     }
diff --git a/crates/runtime-host/src/server/authority.rs b/crates/runtime-host/src/server/authority.rs
index 2d2a4016da..d4223f5baa 100644
--- a/crates/runtime-host/src/server/authority.rs
+++ b/crates/runtime-host/src/server/authority.rs
@@ -244,6 +244,9 @@ fn path_free(request: &Request) -> bool {
         | Operation::SessionTurnsQuery
         | Operation::SessionTurnLandmarksQuery
         | Operation::SessionLifecycleSet
+        | Operation::SessionRemove
+        | Operation::SessionRemovePreview
+        | Operation::SessionRemoveQuery
         | Operation::SessionMetadataUpdate
         | Operation::SessionReadMarkerSet
         | Operation::SessionConfigurationUpdate
diff --git a/crates/runtime-host/src/server/operations.rs b/crates/runtime-host/src/server/operations.rs
index e83986f534..df741b3ce3 100644
--- a/crates/runtime-host/src/server/operations.rs
+++ b/crates/runtime-host/src/server/operations.rs
@@ -267,6 +267,9 @@ impl OperationRegistry for Operations {
                 Operation::SessionSourcesQuery => Some(maka_protocol::session::sources::ERRORS),
                 Operation::SessionCatalogQuery => Some(sessions::QUERY_ERRORS),
                 Operation::SessionLifecycleSet => Some(sessions::LIFECYCLE_ERRORS),
+                Operation::SessionRemove
+                | Operation::SessionRemovePreview
+                | Operation::SessionRemoveQuery => Some(sessions::removal::ERRORS),
                 Operation::SessionMetadataUpdate => Some(sessions::mutation::METADATA_ERRORS),
                 Operation::SessionReadMarkerSet => Some(sessions::mutation::READ_MARKER_ERRORS),
                 Operation::SessionConfigurationUpdate | Operation::SessionWorkspaceRelocate => {
diff --git a/crates/runtime-host/src/server/sessions.rs b/crates/runtime-host/src/server/sessions.rs
index 90129d1f97..16331df67c 100644
--- a/crates/runtime-host/src/server/sessions.rs
+++ b/crates/runtime-host/src/server/sessions.rs
@@ -22,6 +22,7 @@ pub(super) mod copy;
 pub(super) mod create;
 pub(super) use crate::session::model;
 pub(super) mod mutation;
+pub(super) mod removal;
 pub(super) mod workspace;
 
 use crate::session::SessionConfiguration;
@@ -44,6 +45,9 @@ pub(super) enum Output {
     Abandon(maka_protocol::session::copy::AbandonOutput),
     CopyReceipt(maka_protocol::session::copy::QueryResult),
     Sources(maka_protocol::session::sources::Output),
+    Removed(SessionRemoveResult),
+    RemovalPreview(SessionRemovePreviewResult),
+    RemovalReceipt(SessionRemoveQueryResult),
 }
 
 impl Output {
@@ -70,11 +74,23 @@ pub(super) fn supports(operation: Operation) -> bool {
             | Operation::SessionReadMarkerSet
             | Operation::SessionConfigurationUpdate
             | Operation::SessionWorkspaceRelocate
+            | Operation::SessionRemove
+            | Operation::SessionRemovePreview
+            | Operation::SessionRemoveQuery
     )
 }
 
 pub(super) fn decode_input(operation: Operation, value: &Value) -> maka_protocol::Result<Value> {
     match operation {
+        Operation::SessionRemove => {
+            decode_session_remove_input(value)?;
+        }
+        Operation::SessionRemovePreview => {
+            decode_session_remove_preview_input(value)?;
+        }
+        Operation::SessionRemoveQuery => {
+            decode_session_remove_query_input(value)?;
+        }
         Operation::SessionSourcesQuery => {
             maka_protocol::session::sources::decode_input(value)?;
         }
@@ -114,7 +130,13 @@ pub(super) fn decode_input(operation: Operation, value: &Value) -> maka_protocol
 }
 
 pub(super) fn decode_output(operation: Operation, value: &Value) -> maka_protocol::Result<Value> {
-    if matches!(
+    if operation == Operation::SessionRemove {
+        decode_session_remove_result(value)?;
+    } else if operation == Operation::SessionRemovePreview {
+        decode_session_remove_preview_result(value)?;
+    } else if operation == Operation::SessionRemoveQuery {
+        decode_session_remove_query_result(value)?;
+    } else if matches!(
         operation,
         Operation::SessionBranchCreate | Operation::SessionRevisionCreate
     ) {
@@ -147,6 +169,23 @@ pub(super) async fn execute(
 ) -> Result<Output> {
     let log = host.log.as_ref();
     match operation {
+        Operation::SessionRemove => {
+            removal::remove(host, decode_session_remove_input(value).map_err(invalid)?)
+                .await
+                .map(Output::Removed)
+        }
+        Operation::SessionRemovePreview => removal::preview(
+            host,
+            decode_session_remove_preview_input(value).map_err(invalid)?,
+        )
+        .await
+        .map(Output::RemovalPreview),
+        Operation::SessionRemoveQuery => removal::query(
+            host,
+            decode_session_remove_query_input(value).map_err(invalid)?,
+        )
+        .await
+        .map(Output::RemovalReceipt),
         Operation::SessionSourcesQuery => {
             let input = maka_protocol::session::sources::decode_input(value).map_err(invalid)?;
             let messages = log
@@ -359,7 +398,9 @@ fn failure(code: OperationErrorCode, message: &str) -> OperationError {
 
 pub(super) fn stored(error: StoreError) -> OperationError {
     let code = match &error {
-        StoreError::SessionConflict => OperationErrorCode::OperationConflict,
+        StoreError::SessionConflict | StoreError::SessionRetired => {
+            OperationErrorCode::OperationConflict
+        }
         StoreError::SessionNotFound => OperationErrorCode::NotFound,
         StoreError::SessionBusy => OperationErrorCode::SessionBusy,
         StoreError::CommitUnknown(_) | StoreError::OperationUnknown => {
diff --git a/crates/runtime-host/src/server/sessions/copy.rs b/crates/runtime-host/src/server/sessions/copy.rs
index 92716bf9ce..c9225f56a7 100644
--- a/crates/runtime-host/src/server/sessions/copy.rs
+++ b/crates/runtime-host/src/server/sessions/copy.rs
@@ -142,6 +142,7 @@ pub(super) async fn create(host: &Host, input: Input) -> Result<Output> {
 }
 
 async fn finish(host: &Host, input: Input, configuration: &SessionConfiguration) -> Result<Output> {
+    host.executions.validate_workspace(configuration)?;
     let result = host
         .log
         .copy_session(
diff --git a/crates/runtime-host/src/server/sessions/create.rs b/crates/runtime-host/src/server/sessions/create.rs
index 71eff14d36..bde794ec41 100644
--- a/crates/runtime-host/src/server/sessions/create.rs
+++ b/crates/runtime-host/src/server/sessions/create.rs
@@ -93,6 +93,7 @@ pub(super) async fn create(
             host.executions.executor_binding(&id, executor_id)?;
         }
         let config = resolve(&host.configuration, prepared, thinking, workspace).await?;
+        host.executions.validate_workspace(&config)?;
         super::super::projects::record_usage(host, &config.workspace).await?;
         let record = log
             .create_session(
diff --git a/crates/runtime-host/src/server/sessions/removal.rs b/crates/runtime-host/src/server/sessions/removal.rs
new file mode 100644
index 0000000000..2ac2d420d0
--- /dev/null
+++ b/crates/runtime-host/src/server/sessions/removal.rs
@@ -0,0 +1,101 @@
+/*
+ * 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 super::{Result, stored};
+use crate::server::Host;
+use maka_event_log::sessions::RemoveFamilyResult;
+use maka_protocol::{OperationErrorCode as Code, session::*};
+
+pub(super) async fn remove(host: &Host, input: SessionRemoveInput) -> Result<SessionRemoveResult> {
+    let gate = host.interactions.own_admission().await;
+    crate::session::require_unmanaged(&host.log, &input.session_id, Code::OperationConflict)
+        .await?;
+    let result = host
+        .executions
+        .remove_family(
+            input.session_id.clone(),
+            input.expected_revision,
+            maka_event_log::sessions::RemovalAuthority::Unmanaged,
+            gate,
+        )
+        .await?;
+    Ok(match result {
+        RemoveFamilyResult::Accepted(plan) => SessionRemoveResult::Removed {
+            session_id: input.session_id,
+            archived_subtask_count: Some(plan.archived_subtask_count),
+        },
+        RemoveFamilyResult::RevisionConflict { expected, actual } => {
+            SessionRemoveResult::RevisionConflict {
+                expected_revision: expected,
+                actual_revision: actual,
+            }
+        }
+    })
+}
+
+pub(super) async fn preview(
+    host: &Host,
+    input: SessionRemovePreviewInput,
+) -> Result<SessionRemovePreviewResult> {
+    crate::session::require_unmanaged(&host.log, &input.session_id, Code::OperationConflict)
+        .await?;
+    let plan = host
+        .log
+        .preview_session_removal(&input.session_id)
+        .await
+        .map_err(stored)?;
+    Ok(SessionRemovePreviewResult {
+        archivable_subtask_count: plan.archived_subtask_count,
+    })
+}
+
+pub(super) async fn query(
+    host: &Host,
+    input: SessionRemoveQueryInput,
+) -> Result<SessionRemoveQueryResult> {
+    crate::session::require_unmanaged(&host.log, &input.session_id, Code::OperationConflict)
+        .await?;
+    Ok(
+        match host
+            .log
+            .session_removal_receipt(&input.session_id)
+            .await
+            .map_err(stored)?
+        {
+            Some(plan) => SessionRemoveQueryResult::Removed {
+                session_id: input.session_id,
+                archived_subtask_count: plan.archived_subtask_count,
+            },
+            None => SessionRemoveQueryResult::Missing,
+        },
+    )
+}
+
+pub(in crate::server) const ERRORS: &[Code] = &[
+    Code::HostNotReady,
+    Code::HostDraining,
+    Code::OperationUnavailable,
+    Code::InvalidRequest,
+    Code::NotFound,
+    Code::SessionBusy,
+    Code::OperationConflict,
+    Code::PersistenceFailed,
+    Code::CommitOutcomeUnknown,
+    Code::InternalFailure,
+];
diff --git a/crates/runtime-host/src/server/sessions/workspace.rs b/crates/runtime-host/src/server/sessions/workspace.rs
index eadfc38300..14cbaf05cb 100644
--- a/crates/runtime-host/src/server/sessions/workspace.rs
+++ b/crates/runtime-host/src/server/sessions/workspace.rs
@@ -88,6 +88,9 @@ pub(super) async fn relocate(host: &Host, value: &Value) -> Result<SessionUpdate
             "Archived Session workspace cannot be relocated",
         ));
     }
+    let mut destination = current.configuration.clone();
+    destination.workspace = workspace.clone();
+    host.executions.validate_workspace(&destination)?;
     let committed = host
         .log
         .update_session_metadata(
diff --git a/crates/runtime-host/tests/fixtures/background-plugin.mjs b/crates/runtime-host/tests/fixtures/background-plugin.mjs
index aff3728086..4ad355ef7a 100644
--- a/crates/runtime-host/tests/fixtures/background-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/background-plugin.mjs
@@ -219,6 +219,47 @@ export default async function (ctx) {
         if (error.code !== 'revoked') throw error;
       }
       const root = await commands.createRoot(request);
+      let deletion = await ctx.storage.read('removal-intent');
+      if (!deletion) {
+        const target = await commands.createRoot({ ...request, operationId: 'removal-target' });
+        const view = await commands.session(target.sessionId);
+        [deletion] = await ctx.storage.batch([
+          {
+            key: 'removal-intent',
+            expectedRevision: null,
+            data: {
+              kind: 'present',
+              value: { sessionId: target.sessionId, expectedRevision: view.revision },
+            },
+          },
+        ]);
+        if ((await commands.previewRemoval(target.sessionId)) !== 0)
+          throw new Error('independent root acquired unrelated dependents');
+        const stale = await commands.removeSession({
+          sessionId: target.sessionId,
+          expectedRevision: view.revision + 1,
+        });
+        if (stale.kind !== 'revision_conflict')
+          throw new Error('removal ignored its revision fence');
+      }
+      const removal = deletion.data.value;
+      const previousRemoval = await commands.removalReceipt(removal.sessionId);
+      if (!previousRemoval) {
+        const removed = await commands.removeSession(removal);
+        if (removed.kind !== 'removed') throw new Error('authorized removal did not commit');
+      }
+      const durableRemoval = await commands.removalReceipt(removal.sessionId);
+      if (
+        durableRemoval?.sessionId !== removal.sessionId ||
+        durableRemoval.archivedSubtaskCount !== 0
+      )
+        throw new Error('removal receipt did not survive retirement or restart');
+      const repeatedRemoval = await commands.removeSession(removal);
+      if (
+        repeatedRemoval.kind !== 'removed' ||
+        repeatedRemoval.receipt.sessionId !== removal.sessionId
+      )
+        throw new Error('removal retry required the removed catalog record');
       const session = await commands.session(root.sessionId);
       const configured = await commands.configure({
         sessionId: root.sessionId,
diff --git a/crates/runtime-host/tests/integration/main.rs b/crates/runtime-host/tests/integration/main.rs
index 2aff24bbcf..345ce0d1f5 100644
--- a/crates/runtime-host/tests/integration/main.rs
+++ b/crates/runtime-host/tests/integration/main.rs
@@ -82,6 +82,7 @@ mod retirement;
 mod runtime_policy;
 mod scheduler_plugin;
 mod session_history;
+mod session_removal;
 mod skills_management;
 mod skills_plugin;
 mod system_prompt;
diff --git a/crates/runtime-host/tests/integration/session_removal.rs b/crates/runtime-host/tests/integration/session_removal.rs
new file mode 100644
index 0000000000..7bb3c9a7a1
--- /dev/null
+++ b/crates/runtime-host/tests/integration/session_removal.rs
@@ -0,0 +1,204 @@
+/*
+ * 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 super::support::{client_probe::ClientFixture, peer::Peer};
+use maka_event_log::sessions::SessionRetirement;
+use maka_fs_tools::worktree::Worktrees;
+use maka_protocol::session::{SandboxMode, WorkspaceProjection, WorkspaceTarget};
+use maka_runtime_host::{
+    server::{Host, local::LocalListener},
+    session::{PreparedSession, SessionConfiguration, SessionTarget},
+};
+use serde_json::json;
+use sqlx::Connection;
+use std::{
+    process::Command,
+    sync::{Arc, atomic::AtomicBool},
+    time::Duration,
+};
+use tokio_util::sync::CancellationToken;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn removal_recovers_without_its_requester_and_releases_only_the_last_workspace_owner() {
+    let fixture = ClientFixture::new("maka-removal-");
+    let cwd =
+        maka_fs_tools::workspace::project::host_path(&fixture.workspace.canonicalize().unwrap())
+            .unwrap()
+            .to_owned();
+    for args in [
+        vec!["init", "--quiet"],
+        vec!["config", "user.name", "Maka tests"],
+        vec!["config", "user.email", "tests@maka.invalid"],
+        vec!["commit", "--quiet", "--allow-empty", "-m", "base"],
+    ] {
+        let output = Command::new("git")
+            .current_dir(&cwd)
+            .env("GIT_CONFIG_NOSYSTEM", "1")
+            .env(
+                "GIT_CONFIG_GLOBAL",
+                if cfg!(windows) { "NUL" } else { "/dev/null" },
+            )
+            .args(args)
+            .output()
+            .unwrap();
+        assert!(
+            output.status.success(),
+            "{}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+    }
+    let root = fixture.owner().canonical_path().to_owned();
+    let worktrees = Worktrees::open(&root.join("subagent-worktrees")).unwrap();
+    let cancel = Arc::new(AtomicBool::new(false));
+    let binding = worktrees
+        .plan(std::path::Path::new(&cwd), &"a".repeat(64), cancel.clone())
+        .unwrap();
+    worktrees.ensure(&binding, &cancel).unwrap();
+    let configuration = PreparedSession::new(serde_json::from_value(json!({
+        "sessionId":"source", "workspace":{"kind":"host_path","path":cwd}, "executorId":"fixture"
+    })).unwrap()).unwrap().bind(
+        WorkspaceProjection { target: WorkspaceTarget::HostPath { path: cwd.clone() }, host_cwd: cwd.clone() },
+        SessionTarget::Executor { executor_id: "fixture".to_owned().try_into().unwrap(), settings: Default::default() },
+        SandboxMode::DangerFullAccess,
+    );
+    let log = fixture.log().await;
+    log.create_session("source", "source", &configuration, 1)
+        .await
+        .unwrap();
+    let mut shared = configuration.clone();
+    shared.workspace_origin = maka_runtime::execution::WorkspaceOrigin::Allocated;
+    shared.workspace.host_cwd = binding.directory().to_str().unwrap().into();
+    shared.workspace.target = WorkspaceTarget::HostPath {
+        path: shared.workspace.host_cwd.clone(),
+    };
+    shared.worktree = Some(binding.clone());
+    for session in ["first", "last"] {
+        log.create_session(session, session, &shared, 1)
+            .await
+            .unwrap();
+    }
+    log.close().await.unwrap();
+
+    for session in ["first", "last"] {
+        let log = fixture.log().await;
+        log.begin_session_removal(session, 1).await.unwrap();
+        log.close().await.unwrap();
+        // The accepting process disappeared before scheduling any cleanup.
+        let host = Host::open(fixture.owner()).await.unwrap();
+        #[cfg(unix)]
+        let endpoint = fixture.workspace.parent().unwrap().join("removal.sock");
+        #[cfg(windows)]
+        let endpoint =
+            std::path::PathBuf::from(format!(r"\\.\pipe\maka-removal-{}", uuid::Uuid::new_v4()));
+        let stop = CancellationToken::new();
+        let _cleanup = stop.clone().drop_guard();
+        let server = tokio::spawn(
+            LocalListener::bind(&endpoint)
+                .unwrap()
+                .serve(host.clone(), stop.clone()),
+        );
+        let mut peer = Peer::new(host.clone(), "removal").await;
+        let mut read = sqlx::SqliteConnection::connect_with(
+            &sqlx::sqlite::SqliteConnectOptions::new()
+                .filename(root.join(maka_event_log::root::ROOT_DATABASE))
+                .read_only(true),
+        )
+        .await
+        .unwrap();
+        tokio::time::timeout(Duration::from_secs(10), async {
+            while !sqlx::query_scalar::<_, bool>(
+                "SELECT completed FROM session_retirements WHERE session_id=?",
+            )
+            .bind(session)
+            .fetch_one(&mut read)
+            .await
+            .unwrap()
+            {
+                tokio::time::sleep(Duration::from_millis(10)).await;
+            }
+        })
+        .await
+        .unwrap();
+        read.close().await.unwrap();
+        if session == "first" {
+            worktrees.inspect(&binding).unwrap();
+            let selected = peer
+                .rpc(
+                    "session.workspace.relocate",
+                    json!({
+                        "sessionId":"source", "expectedRevision":1,
+                        "workspace":{"kind":"host_path","path":binding.directory()}
+                    }),
+                )
+                .await;
+            assert_eq!(
+                selected["error"]["code"], "operation_conflict",
+                "{selected}"
+            );
+        } else {
+            assert!(!binding.directory().exists());
+            assert!(worktrees.ensure(&binding, &cancel).is_err());
+        }
+        let visible = peer
+            .rpc(
+                "session.catalog.query",
+                json!({"kind":"get","sessionId":"source"}),
+            )
+            .await;
+        assert_eq!(
+            visible["ok"], true,
+            "unrelated Session remains available: {visible}"
+        );
+        if session == "last" {
+            let missing = peer
+                .rpc("session.remove.query", json!({"sessionId":"source"}))
+                .await;
+            assert_eq!(missing["result"]["kind"], "missing", "{missing}");
+            let preview = peer
+                .rpc("session.remove.preview", json!({"sessionId":"source"}))
+                .await;
+            assert_eq!(preview["result"]["archivableSubtaskCount"], 0, "{preview}");
+            let input = json!({"sessionId":"source", "expectedRevision":1});
+            let removed = peer.rpc("session.remove", input.clone()).await;
+            assert_eq!(removed["result"]["kind"], "removed", "{removed}");
+            let replay = peer.rpc("session.remove", input).await;
+            assert_eq!(replay["result"], removed["result"], "{replay}");
+            let receipt = peer
+                .rpc("session.remove.query", json!({"sessionId":"source"}))
+                .await;
+            assert_eq!(receipt["result"], removed["result"], "{receipt}");
+        }
+        peer.close().await;
+        stop.cancel();
+        server.await.unwrap().unwrap();
+        drop(host);
+        let log = fixture.log().await;
+        assert_eq!(
+            log.session_retirement(session).await.unwrap(),
+            Some(SessionRetirement::Removed)
+        );
+        assert!(
+            log.get_session::<SessionConfiguration>(session)
+                .await
+                .unwrap()
+                .is_none()
+        );
+        log.close().await.unwrap();
+    }
+}
diff --git a/crates/runtime/src/event.rs b/crates/runtime/src/event.rs
index 38135ade90..1cc82b76a5 100644
--- a/crates/runtime/src/event.rs
+++ b/crates/runtime/src/event.rs
@@ -326,6 +326,9 @@ impl LogPrefix {
 
 #[derive(Debug, thiserror::Error)]
 pub enum CommitError {
+    /// This Session has stopped accepting work. No effect was admitted.
+    #[error("session retired")]
+    Retired,
     #[error("event rejected: {0}")]
     Rejected(String),
     #[error("event commit outcome unknown: {0}")]
diff --git a/crates/runtime/src/tools/journal.rs b/crates/runtime/src/tools/journal.rs
index c7643b8c9b..b5ac0a1edf 100644
--- a/crates/runtime/src/tools/journal.rs
+++ b/crates/runtime/src/tools/journal.rs
@@ -138,7 +138,12 @@ impl ToolJournal {
                         .map_err(|error| ToolError::Persistence(error.to_string()))?,
                 )
                 .await
-                .map_err(|error| ToolError::Persistence(error.to_string()))?;
+                .map_err(|error| match error {
+                    crate::event::CommitError::Retired => {
+                        ToolError::Failed("session retired before effect".into())
+                    }
+                    other => ToolError::Persistence(other.to_string()),
+                })?;
 
             let result = if cancellation.is_cancelled() {
                 Err(ToolError::Failed("cancelled before effect".into()))
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index 101f644170..521bdc6932 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -91,8 +91,8 @@ implementations; wire adapters may retain existing client vocabulary.
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
 | Usage / Pricing | Usage queries, revision-consistent screens/activity pages, pricing query/mutate and valuation. | **Plugin + Host.** Reports, price policy and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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, retention notices and receipt-based Client recovery exist. Desktop canonical-input editing remains. Regeneration is removed; edits use revision and resend. | **Host.** One transactional lineage and workspace authority; plugins may request changes through commands, not reproduce them in private storage. |
-| Session lifecycle | Removal/preview and shared Session queries. | **Host.** Coordinate references, running work, attachments, owned worktrees, grants and cleanup. Shared queries depend on real collaboration authorization. |
+| 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 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 fa77862718..d12204a671 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -81,8 +81,8 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
 | Usage/Pricing | Usage 查询、一致版本视图及活动分页,Pricing 查询/修改和估价。 | **插件 + Host。** 报表、价格策略和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
-| Session 谱系 | 公共 Rust/JS 历史复制及原始输入读取、原生分支/修订创建与放弃、保留通知及基于回执的 Client 恢复已有。Desktop 规范输入编辑仍待接入。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件可以请求命令,不能在私有存储中重做。 |
-| Session 生命周期 | 删除/预览、shared 查询。 | **Host。** 协调引用、运行中工作、附件、托管 worktree、授权及清理。shared 查询依赖真实协作授权。 |
+| Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
+| 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 ab3c48ab3e..5cb04d815e 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -327,6 +327,18 @@ remaining inherited tool results and compact its own context without changing it
 siblings or earlier frozen reads. A subsequent copy adopts the parent's current projection;
 archive verification always retains the original tool-call evidence.
 
+Session removal commits its revision-family plan, admission fence and queued-message
+cancellations atomically. Dependent Sessions are archived, not destroyed; restoring one
+after cleanup is not undone by an old removal retry. `session.remove.query` recovers the
+accepted receipt even after the catalog entry disappears. Public Rust/JS execution
+capabilities expose the same removal, preview and receipt semantics, requiring authority
+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.
+
 ## Conversation recall
 
 `maka.recall` publishes lazy `Recall`, `RecallMore` and `RecallMaterial` through public history
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 4dfbf6d674..24d412dbc1 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -249,6 +249,15 @@ Desktop 可预览并附加同一 Host 中另一会话的已提交文本快照。
 上下文,不改变源会话、兄弟分支或先前的冻结读取。后续副本继承父会话当前的投影;
 归档校验始终保留原始工具调用证据。
 
+会话删除原子提交修订家族计划、准入关闭和排队消息取消。依赖会话归档而非销毁;
+清理后恢复的依赖会话不会被旧删除重试再次归档。`session.remove.query` 在目录项消失后
+仍可恢复已接受回执。公共 Rust/JS 执行能力提供相同的删除、预览和回执语义,
+必须覆盖全部直接删除成员的权限;历史读取授权不等于删除授权。
+
+Host 等待已接受执行和进程清理后才回收托管 worktree,共享目录保留到最后一个所有者退出。
+不能确认进程清理时保留目录,不阻塞无关会话。规范事实与继承归档证明仍保存在日志中;
+删除会话不等于安全擦除历史数据。
+
 ## 会话回忆
 
 `maka.recall` 通过公共历史能力发布动态加载的 `Recall`、`RecallMore` 和 `RecallMaterial`。
diff --git a/packages/plugin-sdk/src/execution.ts b/packages/plugin-sdk/src/execution.ts
index da8a611522..c07a540c04 100644
--- a/packages/plugin-sdk/src/execution.ts
+++ b/packages/plugin-sdk/src/execution.ts
@@ -177,7 +177,25 @@ export type Configured =
   | { kind: 'committed'; session: SessionConfiguration }
   | { kind: 'revision_conflict'; expectedRevision: number; actualRevision: number };
 
+/** Durable acceptance; resource cleanup may still be pending. */
+export interface RemovalReceipt {
+  sessionId: string;
+  archivedSubtaskCount: number;
+}
+
 export interface Executions {
+  /** Every directly removed revision-family member must be authorized. */
+  removeSession(input: {
+    sessionId: string;
+    expectedRevision: number;
+  }): Promise<
+    | { kind: 'removed'; receipt: RemovalReceipt }
+    | { kind: 'revision_conflict'; expectedRevision: number; actualRevision: number }
+  >;
+  /** Recover acceptance without requiring the deleted catalog record. */
+  removalReceipt(sessionId: string): Promise<RemovalReceipt | null>;
+  /** Advisory dependent-family count, recomputed and authorized on removal. */
+  previewRemoval(sessionId: string): Promise<number>;
   /** Host-local immutable copy; both capability endpoints require current authority. */
   copyAttachment(
     source: Executions,
diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts
index 31b799e46f..f29c40f531 100644
--- a/packages/runtime-host/src/protocol/operations.ts
+++ b/packages/runtime-host/src/protocol/operations.ts
@@ -352,6 +352,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([
   'session.recap.generate',
   'session.remove',
   'session.remove.preview',
+  'session.remove.query',
   'session.revision.abandon',
   'session.revision.create',
   'session.transcript.page',
diff --git a/packages/runtime-host/src/protocol/session-retirement.ts b/packages/runtime-host/src/protocol/session-retirement.ts
index 4eb0183ca5..879f685c75 100644
--- a/packages/runtime-host/src/protocol/session-retirement.ts
+++ b/packages/runtime-host/src/protocol/session-retirement.ts
@@ -59,6 +59,18 @@ export interface SessionRemovePreviewInput {
   readonly sessionId: string;
 }
 
+export interface SessionRemoveQueryInput {
+  readonly sessionId: string;
+}
+
+export type SessionRemoveQueryResult =
+  | { readonly kind: 'missing' }
+  | {
+      readonly kind: 'removed';
+      readonly sessionId: string;
+      readonly archivedSubtaskCount: number;
+    };
+
 export interface SessionRemovePreviewResult {
   /**
    * How many ordinary linked subagent subtasks a delete of this parent would
@@ -142,6 +154,42 @@ export const SESSION_RETIREMENT_OPERATION_SPECS = {
     decodeInput: decodeSessionRemovePreviewInput,
     decodeOutput: decodeSessionRemovePreviewResult,
   }),
+  'session.remove.query': defineOperation<
+    SessionRemoveQueryInput,
+    SessionRemoveQueryResult,
+    (typeof LIFECYCLE_ERRORS)[number]
+  >({
+    mode: 'query',
+    availability: 'ready',
+    errors: LIFECYCLE_ERRORS,
+    decodeInput: (value) => {
+      const input = requireExactRecord(value, 'Session removal receipt query', ['sessionId']);
+      return { sessionId: requireEntityId(input.sessionId, 'sessionId') };
+    },
+    decodeOutput: (value) => {
+      const result = requireRecord(value, 'Session removal receipt');
+      if (result.kind === 'missing') {
+        requireExactRecord(result, 'Missing Session removal receipt', ['kind']);
+        return { kind: 'missing' };
+      }
+      if (result.kind !== 'removed') throw invalidProtocolFrame('Invalid Session removal receipt');
+      const exact = requireExactRecord(result, 'Session removal receipt', [
+        'kind',
+        'sessionId',
+        'archivedSubtaskCount',
+      ]);
+      return {
+        kind: 'removed',
+        sessionId: requireEntityId(exact.sessionId, 'sessionId'),
+        archivedSubtaskCount: requireCount(exact.archivedSubtaskCount, 'archivedSubtaskCount'),
+      };
+    },
+    assertOutputForInput: (input, output) => {
+      if (output.kind === 'removed' && output.sessionId !== input.sessionId) {
+        throw invalidProtocolFrame('Session removal receipt belongs to another Session');
+      }
+    },
+  }),
 } as const;
 
 export function decodeSessionLifecycleSetInput(value: unknown): SessionLifecycleSetInput {
diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts
index 02818a6f80..d95286f08f 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -138,7 +138,10 @@ export type SessionRevisionOperationKey =
   | 'session.branch.create'
   | 'session.revision.create'
   | 'session.revision.abandon';
-export type SessionRetirementOperationKey = keyof typeof SESSION_RETIREMENT_OPERATION_SPECS;
+export type SessionRetirementOperationKey = Exclude<
+  keyof typeof SESSION_RETIREMENT_OPERATION_SPECS,
+  'session.remove.query'
+>;
 export type SessionEffectOperationKey = keyof typeof SESSION_EFFECT_OPERATION_SPECS;
 export type SessionTodoOperationKey = keyof typeof SESSION_TODO_OPERATION_SPECS;
 export type SessionCatalogOperationKey =


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


(maka) 03/09: feat(pricing): add revisioned native rate catalog

Posted by wy...@apache.org.
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

commit defd2ed756c047f90bf9ab13c02a9735aa918771
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 15:38:22 2026 +0800

    feat(pricing): add revisioned native rate catalog
    
    Generated-by: Codex
---
 crates/config/build.rs                             |   1 +
 crates/config/migrations/0001_configuration.sql    |  11 +
 crates/config/src/database.rs                      |   1 +
 crates/config/src/lib.rs                           |   1 +
 crates/config/src/pricing.rs                       | 168 +++++++++++++
 crates/config/src/pricing/catalog.rs               |  92 +++++++
 crates/config/tests/pricing.rs                     | 253 ++++++++++++++++++++
 crates/protocol/src/lib.rs                         |   1 +
 crates/protocol/src/pricing.rs                     | 136 +++++++++++
 crates/runtime-host/src/server.rs                  |   1 +
 crates/runtime-host/src/server/authority.rs        |   2 +
 crates/runtime-host/src/server/dispatch.rs         |   3 +
 crates/runtime-host/src/server/operations.rs       |  14 ++
 crates/runtime-host/src/server/pricing.rs          |  55 +++++
 .../tests/integration/client_pricing.rs}           |  49 ++--
 crates/runtime-host/tests/integration/main.rs      |   1 +
 crates/runtime/src/lib.rs                          |   1 +
 crates/runtime/src/pricing.rs                      | 266 +++++++++++++++++++++
 docs/rust-parity.md                                |   2 +-
 docs/rust-parity.zh-CN.md                          |   2 +-
 docs/rust-runtime.md                               |   4 +-
 docs/rust-runtime.zh-CN.md                         |   3 +-
 scripts/rust/catalog-facts-entry.mjs               |   5 +
 scripts/rust/generate-catalog-facts.mjs            |  19 ++
 tests/fixtures/client-pricing-entry.mjs            | 120 ++++++++++
 tests/fixtures/client.mjs                          |   1 +
 26 files changed, 1178 insertions(+), 34 deletions(-)

diff --git a/crates/config/build.rs b/crates/config/build.rs
index 682d044606..47c4973eac 100644
--- a/crates/config/build.rs
+++ b/crates/config/build.rs
@@ -23,6 +23,7 @@ fn main() {
         "../../scripts/rust/generate-catalog-facts.mjs",
         "../../scripts/rust/catalog-facts-entry.mjs",
         "../../packages/core/src",
+        "../../packages/runtime/src/telemetry/builtin-pricing.ts",
         "../../scripts/sync-model-metadata.mjs",
         "../../scripts/model-metadata/models-dev-api.snapshot.json",
         "../../package-lock.json",
diff --git a/crates/config/migrations/0001_configuration.sql b/crates/config/migrations/0001_configuration.sql
index a0646847bc..1c7deca824 100644
--- a/crates/config/migrations/0001_configuration.sql
+++ b/crates/config/migrations/0001_configuration.sql
@@ -23,6 +23,17 @@ CREATE TABLE connection_catalog (
     default_target TEXT
 );
 INSERT INTO connection_catalog VALUES(1, 0, NULL);
+CREATE TABLE pricing_authority (
+    singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
+    revision INTEGER NOT NULL CHECK(revision BETWEEN 0 AND 9007199254740991),
+    builtin_digest TEXT
+);
+INSERT INTO pricing_authority VALUES(1, 0, NULL);
+CREATE TABLE pricing_overrides (
+    model_key TEXT PRIMARY KEY,
+    sort_key BLOB NOT NULL,
+    record_json TEXT NOT NULL CHECK(json_valid(record_json) AND length(CAST(record_json AS BLOB)) <= 4096)
+);
 CREATE TABLE connections (
     connection_id TEXT PRIMARY KEY,
     slug TEXT NOT NULL UNIQUE,
diff --git a/crates/config/src/database.rs b/crates/config/src/database.rs
index c97d0b406c..8c3fb2584e 100644
--- a/crates/config/src/database.rs
+++ b/crates/config/src/database.rs
@@ -139,5 +139,6 @@ async fn initialize(connection: &mut SqliteConnection) -> std::result::Result<()
     )
     .execute(&mut *connection)
     .await?;
+    crate::pricing::initialize(connection).await?;
     Ok(())
 }
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index ff6bb02863..6ffc73c06c 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -33,6 +33,7 @@ pub mod onboarding;
 pub mod plugin_authorization;
 mod plugin_credentials;
 mod policy;
+pub mod pricing;
 pub mod projection;
 mod vault;
 
diff --git a/crates/config/src/pricing.rs b/crates/config/src/pricing.rs
new file mode 100644
index 0000000000..39bd98efa7
--- /dev/null
+++ b/crates/config/src/pricing.rs
@@ -0,0 +1,168 @@
+/*
+ * 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.
+ */
+
+mod catalog;
+pub(crate) use catalog::initialize;
+
+use crate::{ConfigError, ConfigurationStore, Result, TransactionMode, database::unsigned};
+use maka_runtime::pricing::{Entry, Mutation, Page, Pricing, Query, ResetEffect, Update, Updated};
+use sqlx::{Row, SqliteConnection};
+
+const MAX_REVISION: u64 = 9_007_199_254_740_991;
+const PAGE_ITEMS: usize = 128;
+// Reserve envelope space; each admitted record is bounded independently.
+const PAGE_ENTRY_BYTES: usize = 47 * 1024;
+
+impl ConfigurationStore {
+    /// Continuations are valid only while both bundled and custom rates are unchanged.
+    pub async fn query_pricing(&self, query: Query) -> Result<Page> {
+        self.transaction(TransactionMode::Deferred, move |connection| Box::pin(async move {
+            let revision = revision(connection).await?;
+            let offset = match query {
+                Query::Start => 0,
+                Query::Continue { revision: expected, offset } => {
+                    if expected > MAX_REVISION || offset > i64::MAX as u64 {
+                        return Err(invalid("invalid pricing continuation"));
+                    }
+                    if expected != revision {
+                        return Ok(Page::RevisionChanged { expected_revision: expected, actual_revision: revision });
+                    }
+                    let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM effective_pricing")
+                        .fetch_one(&mut *connection).await?;
+                    if offset >= unsigned(count)? {
+                        return Err(invalid("pricing offset is outside the catalog"));
+                    }
+                    offset
+                }
+            };
+            let rows = sqlx::query(
+                "SELECT record_json, custom, builtin FROM effective_pricing ORDER BY sort_key LIMIT ? OFFSET ?"
+            ).bind((PAGE_ITEMS + 1) as i64).bind(offset as i64)
+                .fetch_all(&mut *connection).await?;
+            let mut entries = Vec::new();
+            let mut bytes = 0;
+            let mut more = false;
+            for row in rows {
+                let pricing = serde_json::from_str(row.try_get("record_json")?)?;
+                let entry = if row.try_get::<bool, _>("custom")? {
+                    Entry::Custom { pricing, reset_effect: if row.try_get::<bool, _>("builtin")? {
+                        ResetEffect::RestoreBuiltin
+                    } else { ResetEffect::BecomeUnpriced } }
+                } else { Entry::Builtin { pricing } };
+                let size = serde_json::to_vec(&entry)?.len() + 1;
+                if entries.len() == PAGE_ITEMS || bytes + size > PAGE_ENTRY_BYTES {
+                    more = true;
+                    break;
+                }
+                bytes += size;
+                entries.push(entry);
+            }
+            let next_offset = more.then_some(offset + entries.len() as u64);
+            Ok(Page::Page { revision, offset, entries, next_offset })
+        })).await
+    }
+
+    /// A quote is a value, not a live lookup. Persist it with the model accounting fact.
+    pub async fn model_pricing(&self, model_key: String) -> Result<(u64, Option<Pricing>)> {
+        maka_runtime::pricing::validate_key(&model_key).map_err(invalid)?;
+        self.transaction(TransactionMode::Deferred, move |connection| {
+            Box::pin(async move {
+                let revision = revision(connection).await?;
+                let record: Option<String> = sqlx::query_scalar(
+                    "SELECT record_json FROM effective_pricing WHERE model_key = ?",
+                )
+                .bind(model_key)
+                .fetch_optional(&mut *connection)
+                .await?;
+                Ok((
+                    revision,
+                    record
+                        .map(|record| serde_json::from_str(&record))
+                        .transpose()?,
+                ))
+            })
+        })
+        .await
+    }
+
+    pub async fn update_pricing(&self, update: Update) -> Result<Updated> {
+        if update.expected_revision > MAX_REVISION {
+            return Err(invalid("invalid pricing revision"));
+        }
+        match &update.mutation {
+            Mutation::Upsert { pricing } => pricing.validate().map_err(invalid)?,
+            Mutation::Delete { model_key } => {
+                maka_runtime::pricing::validate_key(model_key).map_err(invalid)?
+            }
+        }
+        self.transaction(TransactionMode::Immediate, move |connection| Box::pin(async move {
+            let current = revision(connection).await?;
+            if current != update.expected_revision {
+                return Ok(Updated::RevisionConflict {
+                    expected_revision: update.expected_revision, actual_revision: current,
+                });
+            }
+            let changed = match update.mutation {
+                Mutation::Upsert { pricing } => {
+                    let old: Option<String> = sqlx::query_scalar(
+                        "SELECT record_json FROM pricing_overrides WHERE model_key = ?"
+                    ).bind(&pricing.model_key).fetch_optional(&mut *connection).await?;
+                    if old.as_deref().map(serde_json::from_str::<Pricing>).transpose()?.as_ref() == Some(&pricing) {
+                        false
+                    } else {
+                        let record = serde_json::to_string(&pricing)?;
+                        if record.len() > 4096 { return Err(invalid("model price exceeds record limit")); }
+                        sqlx::query(
+                            "INSERT INTO pricing_overrides(model_key, sort_key, record_json) VALUES(?, ?, ?)
+                             ON CONFLICT(model_key) DO UPDATE SET record_json = excluded.record_json"
+                        ).bind(&pricing.model_key).bind(sort_key(&pricing.model_key)).bind(record)
+                            .execute(&mut *connection).await?;
+                        true
+                    }
+                }
+                Mutation::Delete { model_key } => sqlx::query(
+                    "DELETE FROM pricing_overrides WHERE model_key = ?"
+                ).bind(model_key).execute(&mut *connection).await?.rows_affected() != 0,
+            };
+            if !changed { return Ok(Updated::Unchanged { revision: current }); }
+            let revision = current.checked_add(1).filter(|revision| *revision <= MAX_REVISION)
+                .ok_or_else(|| invalid("pricing revision exhausted"))?;
+            sqlx::query("UPDATE pricing_authority SET revision = ? WHERE singleton = 1")
+                .bind(revision as i64).execute(&mut *connection).await?;
+            Ok(Updated::Committed { revision })
+        })).await
+    }
+}
+
+async fn revision(connection: &mut SqliteConnection) -> Result<u64> {
+    unsigned(
+        sqlx::query_scalar("SELECT revision FROM pricing_authority WHERE singleton = 1")
+            .fetch_one(connection)
+            .await?,
+    )
+}
+
+fn invalid(message: impl Into<String>) -> ConfigError {
+    ConfigError::Invalid(message.into())
+}
+
+// Big-endian UTF-16 preserves the client's exact, locale-independent key ordering.
+fn sort_key(key: &str) -> Vec<u8> {
+    key.encode_utf16().flat_map(u16::to_be_bytes).collect()
+}
diff --git a/crates/config/src/pricing/catalog.rs b/crates/config/src/pricing/catalog.rs
new file mode 100644
index 0000000000..c111becf2c
--- /dev/null
+++ b/crates/config/src/pricing/catalog.rs
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+//! Disposable bundled-rate projection. Only overrides and the revision are durable.
+use super::{MAX_REVISION, sort_key};
+use maka_event_log::StoreError;
+use maka_runtime::pricing::Pricing;
+use sqlx::{Connection, QueryBuilder, SqliteConnection};
+use std::sync::LazyLock;
+
+const FACTS: &str = include_str!(concat!(env!("OUT_DIR"), "/pricing-facts.json"));
+const DIGEST: &str = include_str!(concat!(env!("OUT_DIR"), "/pricing-digest.txt"));
+static PRICES: LazyLock<Vec<Pricing>> = LazyLock::new(|| {
+    let prices: Vec<Pricing> = serde_json::from_str(FACTS).expect("generated model rates");
+    for price in &prices {
+        price.validate().expect("valid generated model rate");
+    }
+    prices
+});
+
+pub(crate) async fn initialize(connection: &mut SqliteConnection) -> Result<(), StoreError> {
+    let mut tx = connection.begin_with("BEGIN IMMEDIATE").await?;
+    sqlx::raw_sql(
+        "CREATE TEMP TABLE builtin_pricing (
+            model_key TEXT PRIMARY KEY, sort_key BLOB NOT NULL, record_json TEXT NOT NULL
+        );
+        CREATE TEMP VIEW effective_pricing AS
+            SELECT b.model_key, b.sort_key, b.record_json, 0 AS custom, 1 AS builtin
+            FROM builtin_pricing b WHERE NOT EXISTS (
+                SELECT 1 FROM main.pricing_overrides p WHERE p.model_key = b.model_key
+            )
+            UNION ALL
+            SELECT p.model_key, p.sort_key, p.record_json, 1 AS custom,
+                EXISTS(SELECT 1 FROM builtin_pricing b WHERE b.model_key = p.model_key) AS builtin
+            FROM main.pricing_overrides p;",
+    )
+    .execute(&mut *tx)
+    .await?;
+    for chunk in PRICES.chunks(128) {
+        let mut query =
+            QueryBuilder::new("INSERT INTO builtin_pricing(model_key, sort_key, record_json) ");
+        query.push_values(chunk, |mut row, pricing| {
+            row.push_bind(&pricing.model_key)
+                .push_bind(sort_key(&pricing.model_key))
+                .push_bind(serde_json::to_string(pricing).expect("finite generated rates"));
+        });
+        query.build().execute(&mut *tx).await?;
+    }
+    // An application update can change bundled rates without changing overrides.
+    // Invalidate previously issued cursors and distinguish newly captured quotes.
+    let changed = sqlx::query(
+        "UPDATE pricing_authority
+         SET revision = revision + CASE WHEN builtin_digest IS NULL THEN 0 ELSE 1 END,
+             builtin_digest = ?
+         WHERE singleton = 1 AND builtin_digest IS NOT ?
+           AND (builtin_digest IS NULL OR revision < ?)",
+    )
+    .bind(DIGEST)
+    .bind(DIGEST)
+    .bind(MAX_REVISION as i64)
+    .execute(&mut *tx)
+    .await?;
+    if changed.rows_affected() == 0 {
+        let digest: String =
+            sqlx::query_scalar("SELECT builtin_digest FROM pricing_authority WHERE singleton = 1")
+                .fetch_one(&mut *tx)
+                .await?;
+        if digest != DIGEST {
+            return Err(StoreError::InvalidTransition(
+                "pricing revision exhausted".into(),
+            ));
+        }
+    }
+    tx.commit().await?;
+    Ok(())
+}
diff --git a/crates/config/tests/pricing.rs b/crates/config/tests/pricing.rs
new file mode 100644
index 0000000000..33332d5721
--- /dev/null
+++ b/crates/config/tests/pricing.rs
@@ -0,0 +1,253 @@
+/*
+ * 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_config::ConfigurationStore;
+use maka_event_log::root::{RootNamespaces, RootOwner};
+use maka_runtime::pricing::{Entry, Mutation, Page, Pricing, Query, ResetEffect, Update, Updated};
+use std::sync::Arc;
+
+async fn entries(store: &ConfigurationStore) -> (u64, Vec<Entry>) {
+    let mut query = Query::Start;
+    let mut all = Vec::new();
+    loop {
+        let page = store.query_pricing(query).await.unwrap();
+        assert!(serde_json::to_vec(&page).unwrap().len() <= 48 * 1024);
+        let Page::Page {
+            revision,
+            offset,
+            entries,
+            next_offset,
+        } = page
+        else {
+            panic!("stable snapshot")
+        };
+        assert_eq!(offset, all.len() as u64);
+        assert!(entries.len() <= 128);
+        all.extend(entries);
+        match next_offset {
+            Some(offset) => query = Query::Continue { revision, offset },
+            None => return (revision, all),
+        }
+    }
+}
+
+fn price(entry: &Entry) -> &Pricing {
+    match entry {
+        Entry::Builtin { pricing } | Entry::Custom { pricing, .. } => pricing,
+    }
+}
+
+#[tokio::test]
+async fn pricing_cas_pages_and_quotes_survive_reopen_and_bundled_rate_replacement() {
+    let temp = tempfile::tempdir().unwrap();
+    let owner = Arc::new(
+        RootOwner::create(
+            &temp.path().join("root"),
+            &RootNamespaces {
+                ownership: temp.path().join("owners"),
+                control: temp.path().join("control"),
+            },
+        )
+        .unwrap(),
+    );
+    let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
+    let (revision, initial) = entries(&store).await;
+    assert_eq!(revision, 0);
+    assert!(
+        initial.len() > 128,
+        "exercise pagination against the real offline catalog"
+    );
+    let original = price(&initial[0]).clone();
+    let mut custom = original.clone();
+    custom.input_usd_per_million += 10.0;
+    let update = Update {
+        expected_revision: 0,
+        mutation: Mutation::Upsert {
+            pricing: custom.clone(),
+        },
+    };
+    assert_eq!(
+        store.update_pricing(update.clone()).await.unwrap(),
+        Updated::Committed { revision: 1 }
+    );
+    // A lost mutation reply cannot overwrite a later state on retry.
+    assert_eq!(
+        store.update_pricing(update).await.unwrap(),
+        Updated::RevisionConflict {
+            expected_revision: 0,
+            actual_revision: 1
+        }
+    );
+    assert!(matches!(
+        store
+            .query_pricing(Query::Continue {
+                revision: 0,
+                offset: 128
+            })
+            .await
+            .unwrap(),
+        Page::RevisionChanged {
+            expected_revision: 0,
+            actual_revision: 1
+        }
+    ));
+    assert_eq!(
+        store
+            .update_pricing(Update {
+                expected_revision: 1,
+                mutation: Mutation::Upsert {
+                    pricing: custom.clone()
+                },
+            })
+            .await
+            .unwrap(),
+        Updated::Unchanged { revision: 1 }
+    );
+    let captured = store.model_pricing(custom.model_key.clone()).await.unwrap();
+    assert_eq!(captured, (1, Some(custom.clone())));
+    assert!(entries(&store).await.1.iter().any(|entry| *entry
+        == Entry::Custom {
+            pricing: custom.clone(),
+            reset_effect: ResetEffect::RestoreBuiltin,
+        }));
+
+    // Supplementary-plane keys sort before U+E000 in JS, unlike UTF-8 ordering.
+    let mut revision = 1;
+    for key in ["fixture:\u{e000}", "fixture:\u{10000}"] {
+        revision += 1;
+        assert_eq!(
+            store
+                .update_pricing(Update {
+                    expected_revision: revision - 1,
+                    mutation: Mutation::Upsert {
+                        pricing: Pricing {
+                            model_key: key.into(),
+                            ..custom.clone()
+                        }
+                    },
+                })
+                .await
+                .unwrap(),
+            Updated::Committed { revision }
+        );
+    }
+    let (_, all) = entries(&store).await;
+    assert!(all.windows(2).all(|pair| {
+        price(&pair[0])
+            .model_key
+            .encode_utf16()
+            .cmp(price(&pair[1]).model_key.encode_utf16())
+            .is_lt()
+    }));
+    assert!(
+        all.iter()
+            .filter(|entry| matches!(
+                entry,
+                Entry::Custom {
+                    reset_effect: ResetEffect::BecomeUnpriced,
+                    ..
+                }
+            ))
+            .count()
+            >= 2
+    );
+    assert!(
+        store
+            .query_pricing(Query::Continue {
+                revision,
+                offset: all.len() as u64
+            })
+            .await
+            .is_err()
+    );
+    store.close().await.unwrap();
+
+    let store = ConfigurationStore::for_root(owner.clone()).await.unwrap();
+    assert_eq!(
+        store.model_pricing(custom.model_key.clone()).await.unwrap(),
+        (3, Some(custom.clone()))
+    );
+    assert_eq!(
+        store
+            .update_pricing(Update {
+                expected_revision: 3,
+                mutation: Mutation::Delete {
+                    model_key: custom.model_key.clone()
+                }
+            })
+            .await
+            .unwrap(),
+        Updated::Committed { revision: 4 }
+    );
+    assert_eq!(
+        store.model_pricing(custom.model_key.clone()).await.unwrap(),
+        (4, Some(original))
+    );
+    assert_eq!(
+        captured,
+        (1, Some(custom)),
+        "captured quote is not a mutable pricing reference"
+    );
+    assert_eq!(
+        store
+            .update_pricing(Update {
+                expected_revision: 4,
+                mutation: Mutation::Delete {
+                    model_key: "missing:model".into()
+                }
+            })
+            .await
+            .unwrap(),
+        Updated::Unchanged { revision: 4 }
+    );
+    store.close().await.unwrap();
+
+    // Emulate a binary built from another bundled-rate version; keep overrides.
+    let db = rusqlite::Connection::open(owner.canonical_path().join("configuration-rust.sqlite"))
+        .unwrap();
+    db.execute(
+        "UPDATE pricing_authority SET builtin_digest = 'previous-build'",
+        [],
+    )
+    .unwrap();
+    drop(db);
+    let store = ConfigurationStore::for_root(owner).await.unwrap();
+    assert!(matches!(
+        store
+            .query_pricing(Query::Continue {
+                revision: 4,
+                offset: 1
+            })
+            .await
+            .unwrap(),
+        Page::RevisionChanged {
+            actual_revision: 5,
+            ..
+        }
+    ));
+    assert_eq!(
+        store
+            .model_pricing("fixture:\u{e000}".into())
+            .await
+            .unwrap()
+            .0,
+        5
+    );
+    store.close().await.unwrap();
+}
diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs
index 45e1fdfcb5..68656a668e 100644
--- a/crates/protocol/src/lib.rs
+++ b/crates/protocol/src/lib.rs
@@ -40,6 +40,7 @@ pub mod onboarding;
 pub mod operation;
 pub mod operation_error;
 pub mod plugin;
+pub mod pricing;
 pub mod project;
 pub mod request_headers;
 pub mod resource;
diff --git a/crates/protocol/src/pricing.rs b/crates/protocol/src/pricing.rs
new file mode 100644
index 0000000000..f6dd6a9329
--- /dev/null
+++ b/crates/protocol/src/pricing.rs
@@ -0,0 +1,136 @@
+/*
+ * 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::{Operation, ProtocolError, Result, codec::MAX_SAFE_INTEGER};
+use maka_runtime::pricing::{Mutation, Page, Query, Update, Updated};
+use serde_json::Value;
+
+pub fn supports(operation: Operation) -> bool {
+    matches!(
+        operation,
+        Operation::PricingQuery | Operation::PricingMutate
+    )
+}
+
+pub enum Input {
+    Query(Query),
+    Update(Update),
+}
+
+pub fn decode_input(operation: Operation, value: &Value) -> Result<Input> {
+    match operation {
+        Operation::PricingQuery => {
+            let query: Query = decode(value)?;
+            if let Query::Continue { revision, offset } = query
+                && (revision > MAX_SAFE_INTEGER || offset > MAX_SAFE_INTEGER)
+            {
+                return Err(ProtocolError::invalid("invalid pricing continuation"));
+            }
+            Ok(Input::Query(query))
+        }
+        Operation::PricingMutate => {
+            let input: Update = decode(value)?;
+            if input.expected_revision > MAX_SAFE_INTEGER {
+                return Err(ProtocolError::invalid("invalid pricing revision"));
+            }
+            match &input.mutation {
+                Mutation::Upsert { pricing } => pricing.validate(),
+                Mutation::Delete { model_key } => maka_runtime::pricing::validate_key(model_key),
+            }
+            .map_err(ProtocolError::invalid)?;
+            Ok(Input::Update(input))
+        }
+        _ => Err(ProtocolError::invalid("unknown pricing operation")),
+    }
+}
+
+pub fn decode_output(operation: Operation, value: &Value) -> Result<Value> {
+    match operation {
+        Operation::PricingQuery => {
+            let result: Page = decode(value)?;
+            match result {
+                Page::RevisionChanged {
+                    expected_revision,
+                    actual_revision,
+                } => {
+                    conflict(expected_revision, actual_revision)?;
+                }
+                Page::Page {
+                    revision,
+                    offset,
+                    entries,
+                    next_offset,
+                } => {
+                    if revision > MAX_SAFE_INTEGER
+                        || offset > MAX_SAFE_INTEGER
+                        || entries.len() > 128
+                        || serde_json::to_vec(value).map_err(invalid)?.len() > 48 * 1024
+                        || next_offset.is_some_and(|next| {
+                            entries.is_empty()
+                                || next > MAX_SAFE_INTEGER
+                                || next != offset + entries.len() as u64
+                        })
+                    {
+                        return Err(ProtocolError::invalid("invalid pricing page"));
+                    }
+                    let mut previous: Option<&str> = None;
+                    for entry in &entries {
+                        let (maka_runtime::pricing::Entry::Builtin { pricing }
+                        | maka_runtime::pricing::Entry::Custom { pricing, .. }) = entry;
+                        pricing.validate().map_err(ProtocolError::invalid)?;
+                        if previous.is_some_and(|key| {
+                            key.encode_utf16()
+                                .cmp(pricing.model_key.encode_utf16())
+                                .is_ge()
+                        }) {
+                            return Err(ProtocolError::invalid("pricing page is not ordered"));
+                        }
+                        previous = Some(&pricing.model_key);
+                    }
+                }
+            }
+        }
+        Operation::PricingMutate => match decode::<Updated>(value)? {
+            Updated::Committed { revision } | Updated::Unchanged { revision } => {
+                if revision > MAX_SAFE_INTEGER {
+                    return Err(ProtocolError::invalid("invalid pricing revision"));
+                }
+            }
+            Updated::RevisionConflict {
+                expected_revision,
+                actual_revision,
+            } => conflict(expected_revision, actual_revision)?,
+        },
+        _ => return Err(ProtocolError::invalid("unknown pricing operation")),
+    }
+    Ok(value.clone())
+}
+
+fn conflict(expected: u64, actual: u64) -> Result<()> {
+    if expected > MAX_SAFE_INTEGER || actual > MAX_SAFE_INTEGER || expected == actual {
+        return Err(ProtocolError::invalid("invalid pricing revision conflict"));
+    }
+    Ok(())
+}
+fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
+    serde_json::from_value(value.clone()).map_err(invalid)
+}
+fn invalid(error: impl std::fmt::Display) -> ProtocolError {
+    ProtocolError::invalid(error.to_string())
+}
diff --git a/crates/runtime-host/src/server.rs b/crates/runtime-host/src/server.rs
index aaa362b90f..c2c93bdebe 100644
--- a/crates/runtime-host/src/server.rs
+++ b/crates/runtime-host/src/server.rs
@@ -43,6 +43,7 @@ mod operations;
 mod outbound;
 pub(crate) mod plugin_authorization;
 mod plugin_remote;
+mod pricing;
 mod projects;
 mod sandbox_setup;
 pub(crate) use projects::Usage as ProjectUsage;
diff --git a/crates/runtime-host/src/server/authority.rs b/crates/runtime-host/src/server/authority.rs
index d4223f5baa..cf02d24615 100644
--- a/crates/runtime-host/src/server/authority.rs
+++ b/crates/runtime-host/src/server/authority.rs
@@ -223,6 +223,8 @@ fn path_free(request: &Request) -> bool {
         | Operation::ConnectionCatalogQuery
         | Operation::ExecutorCatalogQuery
         | Operation::ModelProviderCatalogQuery
+        | Operation::PricingQuery
+        | Operation::PricingMutate
         | Operation::ConnectionModelsFetch
         | Operation::ConnectionOnboardingVerify
         | Operation::ConnectionOnboardingSave
diff --git a/crates/runtime-host/src/server/dispatch.rs b/crates/runtime-host/src/server/dispatch.rs
index 8de10d8903..4dde118976 100644
--- a/crates/runtime-host/src/server/dispatch.rs
+++ b/crates/runtime-host/src/server/dispatch.rs
@@ -82,6 +82,9 @@ impl Host {
             self.plugins.wake_background_work();
             return Ok(Outcome::success(serde_json::json!({})));
         }
+        if maka_protocol::pricing::supports(operation) {
+            return super::pricing::execute(self, operation, &input).await;
+        }
         if operation == Operation::HostUpgradePrepare {
             return match self
                 .prepare_retirement(
diff --git a/crates/runtime-host/src/server/operations.rs b/crates/runtime-host/src/server/operations.rs
index df741b3ce3..e2b1fb94c4 100644
--- a/crates/runtime-host/src/server/operations.rs
+++ b/crates/runtime-host/src/server/operations.rs
@@ -30,6 +30,10 @@ pub struct Operations;
 
 impl OperationRegistry for Operations {
     fn decode_input(&self, operation: Operation, value: &Value) -> Result<Value> {
+        if maka_protocol::pricing::supports(operation) {
+            maka_protocol::pricing::decode_input(operation, value)?;
+            return Ok(value.clone());
+        }
         if maka_protocol::sandbox_setup::supports(operation) {
             maka_protocol::sandbox_setup::decode_input(value)?;
             return Ok(value.clone());
@@ -111,6 +115,9 @@ impl OperationRegistry for Operations {
         }
     }
     fn decode_output(&self, operation: Operation, value: &Value) -> Result<Value> {
+        if maka_protocol::pricing::supports(operation) {
+            return maka_protocol::pricing::decode_output(operation, value);
+        }
         if maka_protocol::sandbox_setup::supports(operation) {
             maka_protocol::sandbox_setup::decode_output(value)?;
             return Ok(value.clone());
@@ -187,6 +194,13 @@ impl OperationRegistry for Operations {
         }
     }
     fn error_codes(&self, operation: Operation) -> Option<&[OperationErrorCode]> {
+        if maka_protocol::pricing::supports(operation) {
+            return Some(if operation == Operation::PricingQuery {
+                configuration::QUERY_ERRORS
+            } else {
+                configuration::MUTATION_ERRORS
+            });
+        }
         if maka_protocol::sandbox_setup::supports(operation) {
             return Some(maka_protocol::sandbox_setup::ERRORS);
         }
diff --git a/crates/runtime-host/src/server/pricing.rs b/crates/runtime-host/src/server/pricing.rs
new file mode 100644
index 0000000000..94dbcf5b2a
--- /dev/null
+++ b/crates/runtime-host/src/server/pricing.rs
@@ -0,0 +1,55 @@
+/*
+ * 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 super::{Host, HostError, configuration};
+use maka_protocol::{Operation, Outcome, pricing::Input};
+use maka_runtime::pricing::Updated;
+use serde_json::{Value, json};
+use std::sync::atomic::Ordering;
+
+pub(super) async fn execute(
+    host: &Host,
+    operation: Operation,
+    value: &Value,
+) -> Result<Outcome, HostError> {
+    let output = match maka_protocol::pricing::decode_input(operation, value)? {
+        Input::Query(input) => host
+            .configuration
+            .query_pricing(input)
+            .await
+            .map(serde_json::to_value),
+        Input::Update(input) => host
+            .configuration
+            .update_pricing(input)
+            .await
+            .map(|result| {
+                if matches!(result, Updated::Committed { .. }) {
+                    let revision = host.change_revision.fetch_add(1, Ordering::SeqCst) + 1;
+                    let _ = host
+                        .changes
+                        .send(json!({ "kind": "configuration.changed", "revision": revision }));
+                }
+                serde_json::to_value(result)
+            }),
+    };
+    Ok(match output {
+        Ok(value) => Outcome::success(maka_protocol::pricing::decode_output(operation, &value?)?),
+        Err(error) => Outcome::failure(configuration::failure(error)),
+    })
+}
diff --git a/crates/runtime/src/lib.rs b/crates/runtime-host/tests/integration/client_pricing.rs
similarity index 57%
copy from crates/runtime/src/lib.rs
copy to crates/runtime-host/tests/integration/client_pricing.rs
index 886f4d8f54..ad645696d2 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime-host/tests/integration/client_pricing.rs
@@ -17,33 +17,22 @@
  * under the License.
  */
 
-//! Runtime facts and execution contracts; no V8 or SQLite dependency.
-pub mod access;
-pub mod archive;
-pub mod artifact;
-pub mod attachment;
-pub mod capability;
-pub mod composition;
-pub mod configuration;
-pub mod context;
-pub mod continuation;
-pub mod event;
-mod event_write;
-pub mod execution;
-pub mod executor;
-pub mod handoff;
-pub mod input;
-pub mod interaction;
-pub mod message;
-pub mod model;
-pub mod oauth;
-pub mod provider;
-pub mod read;
-pub mod scope;
-pub mod session;
-pub mod shell_result;
-pub mod shell_run;
-pub mod terminal;
-pub mod tool_call;
-pub mod tool_output;
-pub mod tools;
+use super::support::client_probe::ClientFixture;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn source_client_pages_edits_and_recovers_native_pricing() {
+    let fixture = ClientFixture::new("maka-pricing-client-");
+    for reopened in [false, true] {
+        fixture
+            .run(
+                "--pricing-workspace",
+                reopened,
+                if reopened {
+                    "pricing-reopened"
+                } else {
+                    "pricing-passed"
+                },
+            )
+            .await;
+    }
+}
diff --git a/crates/runtime-host/tests/integration/main.rs b/crates/runtime-host/tests/integration/main.rs
index 345ce0d1f5..6891cd9ac8 100644
--- a/crates/runtime-host/tests/integration/main.rs
+++ b/crates/runtime-host/tests/integration/main.rs
@@ -35,6 +35,7 @@ mod client_onboarding;
 mod client_openai_options;
 mod client_patch;
 mod client_plugins;
+mod client_pricing;
 mod client_questions;
 mod client_remote_access;
 mod client_shell;
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
index 886f4d8f54..22fea9930a 100644
--- a/crates/runtime/src/lib.rs
+++ b/crates/runtime/src/lib.rs
@@ -37,6 +37,7 @@ pub mod interaction;
 pub mod message;
 pub mod model;
 pub mod oauth;
+pub mod pricing;
 pub mod provider;
 pub mod read;
 pub mod scope;
diff --git a/crates/runtime/src/pricing.rs b/crates/runtime/src/pricing.rs
new file mode 100644
index 0000000000..5227124f11
--- /dev/null
+++ b/crates/runtime/src/pricing.rs
@@ -0,0 +1,266 @@
+/*
+ * 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.
+ */
+
+//! Non-secret model rates and auditable estimates. This is not a billing ledger.
+
+use crate::model::ModelUsage;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Query {
+    Start,
+    Continue { revision: u64, offset: u64 },
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "source",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Entry {
+    Builtin {
+        pricing: Pricing,
+    },
+    Custom {
+        pricing: Pricing,
+        reset_effect: ResetEffect,
+    },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ResetEffect {
+    RestoreBuiltin,
+    BecomeUnpriced,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Page {
+    Page {
+        revision: u64,
+        offset: u64,
+        entries: Vec<Entry>,
+        next_offset: Option<u64>,
+    },
+    RevisionChanged {
+        expected_revision: u64,
+        actual_revision: u64,
+    },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Mutation {
+    Upsert { pricing: Pricing },
+    Delete { model_key: String },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Update {
+    pub expected_revision: u64,
+    pub mutation: Mutation,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(
+    tag = "kind",
+    rename_all = "snake_case",
+    rename_all_fields = "camelCase",
+    deny_unknown_fields
+)]
+pub enum Updated {
+    Committed {
+        revision: u64,
+    },
+    Unchanged {
+        revision: u64,
+    },
+    RevisionConflict {
+        expected_revision: u64,
+        actual_revision: u64,
+    },
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Pricing {
+    pub model_key: String,
+    #[serde(rename = "inputUsdPer1M")]
+    pub input_usd_per_million: f64,
+    #[serde(rename = "outputUsdPer1M")]
+    pub output_usd_per_million: f64,
+    #[serde(
+        rename = "cacheReadUsdPer1M",
+        default,
+        deserialize_with = "present_rate",
+        skip_serializing_if = "Option::is_none"
+    )]
+    #[schemars(with = "f64")]
+    pub cache_read_usd_per_million: Option<f64>,
+    #[serde(
+        rename = "cacheWriteUsdPer1M",
+        default,
+        deserialize_with = "present_rate",
+        skip_serializing_if = "Option::is_none"
+    )]
+    #[schemars(with = "f64")]
+    pub cache_write_usd_per_million: Option<f64>,
+}
+
+impl Pricing {
+    pub fn validate(&self) -> Result<(), &'static str> {
+        validate_key(&self.model_key)?;
+        for rate in [
+            Some(self.input_usd_per_million),
+            Some(self.output_usd_per_million),
+            self.cache_read_usd_per_million,
+            self.cache_write_usd_per_million,
+        ]
+        .into_iter()
+        .flatten()
+        {
+            if !rate.is_finite() || rate < 0.0 {
+                return Err("invalid model price");
+            }
+        }
+        Ok(())
+    }
+
+    /// Input and output totals include their cache and reasoning subsets.
+    /// Optional cache rates inherit the input rate, never a made-up discount.
+    /// Missing total usage or an unrepresentable estimate is not zero cost.
+    pub fn estimate(&self, usage: &ModelUsage) -> Result<Option<f64>, &'static str> {
+        self.validate()?;
+        let (Some(input), Some(output)) = (usage.input_tokens, usage.output_tokens) else {
+            return Ok(None);
+        };
+        if input != 0
+            && ((usage.cache_read_tokens.is_none()
+                && self
+                    .cache_read_usd_per_million
+                    .is_some_and(|rate| rate != self.input_usd_per_million))
+                || (usage.cache_write_tokens.is_none()
+                    && self
+                        .cache_write_usd_per_million
+                        .is_some_and(|rate| rate != self.input_usd_per_million)))
+        {
+            return Ok(None);
+        }
+        let read = usage.cache_read_tokens.unwrap_or(0).min(input);
+        let write = usage.cache_write_tokens.unwrap_or(0).min(input - read);
+        let uncached = input - read - write;
+        // Scale the count before multiplication: a finite rate need not fit when
+        // multiplied by a whole token count but may still produce a finite cost.
+        let amount = uncached as f64 / 1_000_000.0 * self.input_usd_per_million
+            + read as f64 / 1_000_000.0
+                * self
+                    .cache_read_usd_per_million
+                    .unwrap_or(self.input_usd_per_million)
+            + write as f64 / 1_000_000.0
+                * self
+                    .cache_write_usd_per_million
+                    .unwrap_or(self.input_usd_per_million)
+            + output as f64 / 1_000_000.0 * self.output_usd_per_million;
+        Ok(amount.is_finite().then_some(amount))
+    }
+}
+
+/// Model keys are exact identities, not normalized provider or model names.
+pub fn validate_key(key: &str) -> Result<(), &'static str> {
+    if key.is_empty()
+        || key.trim() != key
+        || key.encode_utf16().count() > 128
+        || key.chars().any(char::is_control)
+    {
+        return Err("invalid pricing model key");
+    }
+    Ok(())
+}
+
+fn present_rate<'de, D: serde::Deserializer<'de>>(
+    deserializer: D,
+) -> Result<Option<f64>, D::Error> {
+    f64::deserialize(deserializer).map(Some)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn estimate_preserves_unknowns_and_does_not_charge_token_subsets_twice() {
+        let price = Pricing {
+            model_key: "provider:model".into(),
+            input_usd_per_million: 10.0,
+            output_usd_per_million: 20.0,
+            cache_read_usd_per_million: Some(1.0),
+            cache_write_usd_per_million: Some(12.0),
+        };
+        let mut usage = ModelUsage {
+            input_tokens: Some(1_000_000),
+            output_tokens: Some(1_000_000),
+            cache_read_tokens: Some(200_000),
+            cache_write_tokens: Some(100_000),
+            reasoning_tokens: Some(500_000),
+        };
+        assert_eq!(price.estimate(&usage).unwrap(), Some(28.4));
+        usage.input_tokens = None;
+        assert_eq!(price.estimate(&usage).unwrap(), None);
+        usage.input_tokens = Some(0);
+        usage.output_tokens = Some(0);
+        assert_eq!(price.estimate(&usage).unwrap(), Some(0.0));
+        // An impossible cache report cannot produce a negative uncached charge.
+        usage.input_tokens = Some(100_000);
+        assert_eq!(price.estimate(&usage).unwrap(), Some(0.1));
+        usage.cache_read_tokens = None;
+        assert_eq!(
+            price.estimate(&usage).unwrap(),
+            None,
+            "unknown cache split changes the price"
+        );
+        let huge = Pricing {
+            input_usd_per_million: f64::MAX,
+            cache_read_usd_per_million: None,
+            cache_write_usd_per_million: None,
+            ..price
+        };
+        usage.cache_read_tokens = None;
+        usage.cache_write_tokens = None;
+        usage.input_tokens = Some(1);
+        assert!(huge.estimate(&usage).unwrap().unwrap().is_finite());
+        usage.input_tokens = Some(u64::MAX);
+        assert_eq!(huge.estimate(&usage).unwrap(), None);
+    }
+}
diff --git a/docs/rust-parity.md b/docs/rust-parity.md
index e3602edb9e..29d6e26381 100644
--- a/docs/rust-parity.md
+++ b/docs/rust-parity.md
@@ -89,7 +89,7 @@ implementations; wire adapters may retain existing client vocabulary.
 | Goal | Query/arm/control, continuation, termination, budget and recovery semantics. | **Plugin + Host.** Goal policy owns subsequent submissions; Host enforces admitted hard limits and records usage. Retiring the plugin must close future submission admission. |
 | Daily review / recap | Daily-review query/mutate, scheduled review and `session.recap.generate`. | **Plugin + Host.** Summarization/selection/output in plugins; reuse Scheduler and authorized history/model services. Host retains any canonical Session metadata commit. |
 | External agents | Setup start/query/cancel; execution adapters, configuration, auth, conversation identity, adapter-specific attachments/interactions/resume/fork. | **Plugin + Host.** Implement concrete CLI/ACP adapters as Executor plugins over owned processes/HTTP. Generic Executor support is not a shipped adapter. Host owns authorization, cancellation and canonical external-event recording. |
-| Usage / Pricing | Physical Agent and auxiliary SDK call accounting and bounded source reads are implemented. Public Usage queries, revision-consistent screens/activity pages, pricing query/mutate and valuation remain. | **Plugin + Host.** Reports, price policy and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
+| Usage / Pricing | Physical Agent and auxiliary SDK accounting, bounded source reads, and native pricing query/CAS edits are implemented. Public plugin accounting/pricing capabilities, revision-consistent Usage screens/activity pages and per-call valuation remain. | **Plugin + Host.** Reports and rebuildable projections may be an Insights domain; Host records usage independently of plugin availability and provides consistent reads. Missing usage must not become zero. |
 | 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. |
diff --git a/docs/rust-parity.zh-CN.md b/docs/rust-parity.zh-CN.md
index c4fe08de40..db16ffdfc2 100644
--- a/docs/rust-parity.zh-CN.md
+++ b/docs/rust-parity.zh-CN.md
@@ -79,7 +79,7 @@ runtime 契约不能反向依赖插件实现,协议适配层可以保留现有
 | Goal | 查询、arm、控制、续跑、终止、预算与恢复语义。 | **插件 + Host。** Goal 决定后续提交;Host 执行已准入的硬限制并记录用量。插件退休后不能继续提交。 |
 | Daily review/recap | daily-review 查询/修改、定时复盘、`session.recap.generate`。 | **插件 + Host。** 选择、总结及输出由插件负责,复用 Scheduler 和授权历史/模型服务;规范 Session 元数据的提交仍归 Host。 |
 | 外部 agent | setup start/query/cancel;具体执行适配、配置、鉴权、对话身份,以及附件/交互/resume/fork。 | **插件 + Host。** CLI/ACP 适配作为 Executor 插件,使用受管理进程/HTTP。已有 Executor 框架不等于已有具体 adapter。Host 负责授权、取消和外部事件落盘。 |
-| Usage/Pricing | 已实现 Agent 与辅助 SDK 调用的物理准入计量及有界事实读取。公共 Usage 查询、一致版本视图及活动分页、Pricing 查询/修改和估价仍待接入。 | **插件 + Host。** 报表、价格策略和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
+| Usage/Pricing | 已实现 Agent 与辅助 SDK 的物理计量、有界事实读取及原生报价查询/CAS 修改。公共插件计量/报价能力、一致版本的 Usage 视图与活动分页、逐调用估价仍待接入。 | **插件 + Host。** 报表和可重建投影可归 Insights 领域;Host 不依赖插件存活来记录用量,并提供一致快照。缺失用量不能视为零。 |
 | 后台健康 | BackgroundTaskHealth 的进程和端点检查。 | **插件 + Host。** 插件解释健康状态并提供工具;Host 提供授权资源观察和有界探测。保存 PID 不等于拥有进程。 |
 | Session 谱系 | 已实现公共 Rust/JS 历史复制和原始输入读取、原生分支/修订创建与放弃、继承历史独立裁剪、Desktop 规范输入编辑及完整持久草稿。已移除 regenerate,修改请求使用编辑重发。 | **Host。** 谱系及工作区只有一个事务权威;插件请求命令,不在私有存储中重做谱系。 |
 | Session 生命周期 | 已实现原生与公共插件删除/预览/回执接口、原子家族退休及队列取消、重启清理和共享 worktree 所有权。无引用规范材料回收及 shared 查询仍待完成。 | **Host。** 保留被引用历史及已接受回执;进程清理不确定时不得删除工作目录。shared 查询需要真实协作授权。 |
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 033433529e..98311a05ed 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -215,7 +215,9 @@ user/project content paths stay separate from private journals.
 - Accounting counts physical model admissions, including failed retries and auxiliary
   plugin calls. Provider usage survives rejected output; missing counters and unresolved
   outcomes remain unknown. History copies do not duplicate usage, and Session removal
-  retains accounting facts. Usage screens and pricing are not yet connected.
+  retains accounting facts. Pricing queries and CAS edits use a bounded, revision-pinned
+  catalog; custom rates survive restart and bundled-rate updates invalidate old cursors.
+  Usage screens and per-call valuation are not yet connected.
 - Subscriptions deliver frames only after `subscription.ready`. Reconnection replays
   active text from the committed log under backpressure; no copied transcript overlay.
 - Model messages, content and tool outcomes are typed through provider projection.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index 024d12fd68..5c49af6090 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -168,7 +168,8 @@ Host 不可连接不代表进程已停止。`host logs --root-id <rootId>` 返
   失败响应的片段只用于展示,不纳入模型历史;用户取消不显示为 provider 失败。
 - 计量按模型物理准入统计,包含失败重试和插件辅助调用。被拒绝的回复仍保留已报告用量;
   缺失计数与未确认结果保持未知。历史复制不重复计量,Session 删除保留计量事实。
-  Usage 页面与 Pricing 尚未接入。
+  报价支持有界、固定版本的查询与 CAS 修改;自定义报价跨重启保留,内置报价更新使旧分页失效。
+  Usage 页面与逐调用估价尚未接入。
 - 模型消息、内容块与工具结果在 provider 投影中保持类型化;路由与发现共享类型化契约。
   工具 JSON、schema 和厂商扩展保留开放结构。
 - 订阅收到 `subscription.ready` 后才交付帧。重连按背压从已提交日志补发活动文本,
diff --git a/scripts/rust/catalog-facts-entry.mjs b/scripts/rust/catalog-facts-entry.mjs
index 6fd7551073..2f979840aa 100644
--- a/scripts/rust/catalog-facts-entry.mjs
+++ b/scripts/rust/catalog-facts-entry.mjs
@@ -32,6 +32,11 @@ import {
   buildConnectionModelCatalogEntries,
 } from '../../packages/core/src/model-catalog.ts';
 import { getModelCapabilities } from '@ai-sdk/anthropic/internal';
+import { BUILTIN_PRICING } from '../../packages/runtime/src/telemetry/builtin-pricing.ts';
+
+export function outputPricingFacts() {
+  return BUILTIN_PRICING;
+}
 
 export function outputProviderFacts() {
   return Object.fromEntries(
diff --git a/scripts/rust/generate-catalog-facts.mjs b/scripts/rust/generate-catalog-facts.mjs
index d1c48b161b..d1cdcd5bbe 100644
--- a/scripts/rust/generate-catalog-facts.mjs
+++ b/scripts/rust/generate-catalog-facts.mjs
@@ -18,6 +18,7 @@
  */
 
 import { createRequire } from 'node:module';
+import { createHash } from 'node:crypto';
 import { readFile, writeFile } from 'node:fs/promises';
 import { isAbsolute, join, resolve } from 'node:path';
 import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -26,6 +27,7 @@ const directory = resolve(process.argv[2]);
 const require = createRequire(join(resolve(process.env.MAKA_JS_DEPS || root), 'package.json'));
 const { build } = require('esbuild');
 const metadata = join(directory, 'model-metadata.generated.ts');
+const pricing = join(directory, 'model-pricing.generated.ts');
 const { main } = await import('../sync-model-metadata.mjs');
 await main([
   'node',
@@ -34,6 +36,8 @@ await main([
   join(root, 'scripts/model-metadata/models-dev-api.snapshot.json'),
   '--output',
   metadata,
+  '--pricing-output',
+  pricing,
 ]);
 const bundle = join(directory, 'catalog-facts-source.mjs');
 const result = await build({
@@ -52,6 +56,9 @@ const result = await build({
         build.onResolve({ filter: /^\.\/model-metadata\.generated\.js$/ }, () => ({
           path: metadata,
         }));
+        build.onResolve({ filter: /^\.\/model-pricing\.generated\.js$/ }, () => ({
+          path: pricing,
+        }));
         build.onResolve({ filter: /^@maka\// }, async ({ path }) => {
           const [, name, ...subpath] = path.split('/');
           const packageRoot = join(root, 'packages', name);
@@ -89,3 +96,15 @@ await writeFile(
   JSON.stringify(source.outputProviderFacts()),
 );
 await writeFile(join(directory, 'catalog-oracle.json'), JSON.stringify(source.oracleFixtures()));
+const pricingFacts = JSON.stringify(
+  source
+    .outputPricingFacts()
+    .sort((left, right) =>
+      left.modelKey < right.modelKey ? -1 : left.modelKey > right.modelKey ? 1 : 0,
+    ),
+);
+await writeFile(join(directory, 'pricing-facts.json'), pricingFacts);
+await writeFile(
+  join(directory, 'pricing-digest.txt'),
+  createHash('sha256').update(pricingFacts).digest('hex'),
+);
diff --git a/tests/fixtures/client-pricing-entry.mjs b/tests/fixtures/client-pricing-entry.mjs
new file mode 100644
index 0000000000..a8af2b64bd
--- /dev/null
+++ b/tests/fixtures/client-pricing-entry.mjs
@@ -0,0 +1,120 @@
+/*
+ * 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.
+ */
+
+import assert from 'node:assert/strict';
+import { connect } from 'node:net';
+import { once } from 'node:events';
+import { parseArgs } from 'node:util';
+import { connectRuntimeHostMessageTransport } from '../../packages/runtime-host/src/client/connection.ts';
+import { FramedTransport } from '../../packages/runtime-host/src/transport/framed-transport.ts';
+import {
+  INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+  RUNTIME_HOST_PROTOCOL_VERSION,
+} from '../../packages/runtime-host/src/protocol/index.ts';
+
+const { values } = parseArgs({
+  options: {
+    socket: { type: 'string' },
+    'root-id': { type: 'string' },
+    'pricing-workspace': { type: 'string' },
+    reopened: { type: 'boolean' },
+  },
+});
+const socket = connect(values.socket);
+const transport = new FramedTransport(socket);
+let connection;
+try {
+  await once(socket, 'connect');
+  const connected = await connectRuntimeHostMessageTransport({
+    transport,
+    expectedRootId: values['root-id'],
+    compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
+    protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
+    handshakeTimeoutMs: 3000,
+    livenessIntervalMs: 60000,
+  });
+  assert.equal(connected.kind, 'connected');
+  connection = connected.connection;
+  const request = (operation, input) => connection.request(operation, input, 5000);
+  const snapshot = async () => {
+    let page = await request('pricing.query', { kind: 'start' });
+    const revision = page.revision;
+    const entries = [];
+    while (true) {
+      assert.equal(page.kind, 'page');
+      assert.equal(page.revision, revision);
+      assert.equal(page.offset, entries.length);
+      entries.push(...page.entries);
+      if (page.nextOffset === null) return { revision, entries };
+      page = await request('pricing.query', {
+        kind: 'continue',
+        revision,
+        offset: page.nextOffset,
+      });
+    }
+  };
+  const initial = await snapshot();
+  assert(initial.entries.length > 128);
+  const key = 'fixture:priced-model';
+  const pricing = { modelKey: key, inputUsdPer1M: 2.5, outputUsdPer1M: 10 };
+  if (values.reopened) {
+    assert.equal(initial.revision, 1);
+    assert.deepEqual(
+      initial.entries.find(({ pricing }) => pricing.modelKey === key),
+      {
+        source: 'custom',
+        pricing,
+        resetEffect: 'become_unpriced',
+      },
+    );
+    assert.deepEqual(
+      await request('pricing.mutate', {
+        expectedRevision: 1,
+        mutation: { kind: 'delete', modelKey: key },
+      }),
+      { kind: 'committed', revision: 2 },
+    );
+    assert(!(await snapshot()).entries.some(({ pricing }) => pricing.modelKey === key));
+  } else {
+    assert.equal(initial.revision, 0);
+    const mutation = { expectedRevision: 0, mutation: { kind: 'upsert', pricing } };
+    assert.deepEqual(await request('pricing.mutate', mutation), { kind: 'committed', revision: 1 });
+    assert.deepEqual(await request('pricing.mutate', mutation), {
+      kind: 'revision_conflict',
+      expectedRevision: 0,
+      actualRevision: 1,
+    });
+    assert.deepEqual(
+      await request('pricing.query', {
+        kind: 'continue',
+        revision: 0,
+        offset: 128,
+      }),
+      { kind: 'revision_changed', expectedRevision: 0, actualRevision: 1 },
+    );
+    assert.deepEqual(await request('pricing.mutate', { ...mutation, expectedRevision: 1 }), {
+      kind: 'unchanged',
+      revision: 1,
+    });
+  }
+  console.log(values.reopened ? 'pricing-reopened' : 'pricing-passed');
+} finally {
+  transport.abort();
+  await connection?.close();
+}
diff --git a/tests/fixtures/client.mjs b/tests/fixtures/client.mjs
index be69ab7a22..45ae42a4aa 100644
--- a/tests/fixtures/client.mjs
+++ b/tests/fixtures/client.mjs
@@ -22,6 +22,7 @@ import { spawnSync } from 'node:child_process';
 import { withSourceBundle } from '../support/source.mjs';
 
 const entries = [
+  ['--pricing-workspace', 'client-pricing-entry.mjs'],
   ['--sandbox-workspace', 'client-sandbox-entry.mjs'],
   ['--skills-client-workspace', 'client-skills-entry.mjs'],
   ['--scheduler-workspace', 'client-scheduler-entry.mjs'],


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


(maka) 05/09: perf(usage): pin accounting reads and avoid full response scans

Posted by wy...@apache.org.
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

commit b2d13184f9e21213f73b77a77bda3a2e62790840
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 23 16:15:33 2026 +0800

    perf(usage): pin accounting reads and avoid full response scans
    
    Generated-by: Codex
---
 crates/event-log/migrations/0001_runtime.sql       | 51 +++++++++-----
 crates/event-log/src/usage.rs                      | 20 +++++-
 crates/event-log/tests/auxiliary_usage.rs          |  7 ++
 crates/event-log/tests/usage.rs                    | 77 ++++++++--------------
 crates/event-log/tests/usage/fixture.rs            | 69 +++++++++++++++++++
 .../tests/integration/client_pricing.rs            |  1 +
 .../integration/javascript_plugins/metering.rs     |  1 +
 .../tests/integration/plugin_authorization.rs      |  1 +
 8 files changed, 158 insertions(+), 69 deletions(-)

diff --git a/crates/event-log/migrations/0001_runtime.sql b/crates/event-log/migrations/0001_runtime.sql
index a33b6c96f6..2f40d64bfa 100644
--- a/crates/event-log/migrations/0001_runtime.sql
+++ b/crates/event-log/migrations/0001_runtime.sql
@@ -25,6 +25,30 @@ CREATE TABLE event_log (
     invocation_id TEXT,
     kind TEXT NOT NULL,
     operation_id TEXT,
+    -- Keep compact accounting fields before potentially large JSON overflow
+    -- pages. SQLite derives them from canonical bytes; they cannot be edited.
+    accounting_at REAL GENERATED ALWAYS AS (
+        CASE WHEN kind IN ('model_requested', 'model_completed', 'model_interrupted',
+                           'invocation_ended', 'auxiliary_model_started', 'auxiliary_model_settled')
+        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
+        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')
+        END
+    ) STORED,
+    invocation_model TEXT GENERATED ALWAYS AS (
+        CASE WHEN kind = 'invocation_opened' THEN json_extract(event_json, '$.fact.configuration.model') END
+    ) STORED,
     event_json TEXT NOT NULL,
     CHECK(invocation_id IS NOT NULL OR operation_id IS NULL)
 );
@@ -317,13 +341,12 @@ CREATE VIEW agent_model_usage AS
 WITH attempts AS (
     SELECT request.sequence, request.event_id,
         json_extract(request.event_json, '$.invocation') AS invocation,
-        json_extract(opening.event_json, '$.fact.configuration.model') AS binding,
+        opening.invocation_model AS binding,
         json_extract(request.event_json, '$.fact.model_id') AS model_id,
         json_extract(request.event_json, '$.fact.purpose') AS purpose,
-        json_extract(request.event_json, '$.recorded_at') AS started,
-        COALESCE(json_extract(completed.event_json, '$.recorded_at'),
-                 json_extract(interrupted.event_json, '$.recorded_at'),
-                 json_extract(terminal.event_json, '$.recorded_at')) AS ended,
+        request.accounting_at AS started_at,
+        COALESCE(completed.accounting_at, interrupted.accounting_at, terminal.accounting_at) AS completed_at,
+        COALESCE(completed.sequence, interrupted.sequence, terminal.sequence) AS completed_sequence,
         CASE
             WHEN completed.sequence IS NOT NULL THEN 'success'
             WHEN json_extract(interrupted.event_json, '$.fact.status') = 'cancelled' THEN 'aborted'
@@ -331,8 +354,8 @@ WITH attempts AS (
             WHEN json_extract(terminal.event_json, '$.fact.outcome.kind') = 'cancelled' THEN 'aborted'
             WHEN terminal.sequence IS NOT NULL THEN 'unknown'
         END AS outcome,
-        COALESCE(json_extract(completed.event_json, '$.fact.output.usage'), (
-            SELECT json_extract(observed.event_json, '$.fact.event.data.usage')
+        COALESCE(completed.accounting_usage, (
+            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'
@@ -355,10 +378,8 @@ SELECT sequence, event_id,
     json_object('kind', 'agent', 'invocation', json(invocation), 'purpose', purpose) AS origin,
     binding, model_id, outcome, usage,
     json_extract(invocation, '$.session_id') AS session_id,
-    json_extract(started, '$.secs_since_epoch') * 1000.0
-        + json_extract(started, '$.nanos_since_epoch') / 1000000.0 AS started_at,
-    json_extract(ended, '$.secs_since_epoch') * 1000.0
-        + json_extract(ended, '$.nanos_since_epoch') / 1000000.0 AS completed_at
+    started_at, completed_at,
+    completed_sequence
 FROM attempts;
 
 CREATE UNIQUE INDEX auxiliary_model_source ON event_log(json_extract(event_json, '$.source'))
@@ -373,12 +394,10 @@ SELECT started.sequence, started.event_id,
     json_extract(started.event_json, '$.binding') AS binding,
     json_extract(started.event_json, '$.binding.model') AS model_id,
     json_extract(settled.event_json, '$.outcome') AS outcome,
-    COALESCE(json_extract(observed.event_json, '$.usage'), '{}') AS usage,
+    COALESCE(observed.accounting_usage, '{}') AS usage,
     json_extract(started.event_json, '$.session_id') AS session_id,
-    json_extract(started.event_json, '$.started_at.secs_since_epoch') * 1000.0
-        + json_extract(started.event_json, '$.started_at.nanos_since_epoch') / 1000000.0 AS started_at,
-    json_extract(settled.event_json, '$.completed_at.secs_since_epoch') * 1000.0
-        + json_extract(settled.event_json, '$.completed_at.nanos_since_epoch') / 1000000.0 AS completed_at
+    started.accounting_at AS started_at, settled.accounting_at AS completed_at,
+    settled.sequence AS completed_sequence
 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
diff --git a/crates/event-log/src/usage.rs b/crates/event-log/src/usage.rs
index ef646f298d..ad30a3765c 100644
--- a/crates/event-log/src/usage.rs
+++ b/crates/event-log/src/usage.rs
@@ -37,6 +37,8 @@ pub struct Query {
     pub from: f64,
     pub to: f64,
     pub session_id: Option<String>,
+    /// Inclusive canonical fence from the first page, independent of wall-clock time.
+    pub through: Option<u64>,
 }
 
 impl Query {
@@ -97,6 +99,7 @@ impl ModelAttempt {
 }
 
 pub struct ModelPage {
+    pub through: u64,
     pub attempts: Vec<ModelAttempt>,
     pub total: u64,
     pub next_offset: Option<u64>,
@@ -121,14 +124,24 @@ impl EventLog {
             .run(move |connection| {
                 Box::pin(async move {
                     let mut tx = connection.begin().await?;
+                    let current = crate::sequence_number(
+                        sqlx::query_scalar("SELECT COALESCE(MAX(sequence), 0) FROM event_log")
+                            .fetch_one(&mut *tx)
+                            .await?,
+                    )?;
+                    let through = query.through.unwrap_or(current);
+                    if through > current {
+                        return Err(invalid("Usage fence is in the future"));
+                    }
                     let total: i64 = sqlx::query_scalar(
                         "SELECT COUNT(*) FROM model_usage
                  WHERE completed_at >= ?1 AND completed_at <= ?2
-                 AND (?3 IS NULL OR session_id = ?3)",
+                 AND (?3 IS NULL OR session_id = ?3) AND completed_sequence <= ?4",
                     )
                     .bind(query.from)
                     .bind(query.to)
                     .bind(&query.session_id)
+                    .bind(through as i64)
                     .fetch_one(&mut *tx)
                     .await?;
                     let rows = sqlx::query(
@@ -137,11 +150,13 @@ impl EventLog {
                  FROM model_usage
                  WHERE completed_at >= ?1 AND completed_at <= ?2
                  AND (?3 IS NULL OR session_id = ?3)
-                 ORDER BY completed_at DESC, sequence DESC LIMIT ?4 OFFSET ?5",
+                 AND completed_sequence <= ?4
+                 ORDER BY completed_at DESC, sequence DESC LIMIT ?5 OFFSET ?6",
                     )
                     .bind(query.from)
                     .bind(query.to)
                     .bind(query.session_id)
+                    .bind(through as i64)
                     .bind(limit)
                     .bind(offset as i64)
                     .fetch_all(&mut *tx)
@@ -151,6 +166,7 @@ impl EventLog {
                     let end = offset + attempts.len() as u64;
                     tx.commit().await?;
                     Ok(ModelPage {
+                        through,
                         attempts,
                         total,
                         next_offset: (end < total).then_some(end),
diff --git a/crates/event-log/tests/auxiliary_usage.rs b/crates/event-log/tests/auxiliary_usage.rs
index d743a4eafe..0d1b9cb699 100644
--- a/crates/event-log/tests/auxiliary_usage.rs
+++ b/crates/event-log/tests/auxiliary_usage.rs
@@ -60,6 +60,7 @@ fn query() -> Query {
         from: 0.0,
         to: f64::MAX,
         session_id: None,
+        through: None,
     }
 }
 
@@ -155,6 +156,8 @@ async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_fai
     log.observe_auxiliary_model(interrupted, complete_usage.clone())
         .await
         .unwrap();
+    let mut fixed = query();
+    fixed.through = Some(log.model_attempts(query(), 0, 100).await.unwrap().through);
     log.close().await.unwrap();
     let log = EventLog::open(&path).await.unwrap();
     log.recover_host_effects().await.unwrap();
@@ -168,5 +171,9 @@ async fn auxiliary_accounting_requires_admission_and_preserves_usage_through_fai
     assert_eq!(after.attempts[0].quote, Some(quote));
     assert_eq!(after.attempts[0].cost_usd, Some(5.0));
     assert_eq!(after.attempts[1], *attempt);
+    assert_eq!(
+        log.model_attempts(fixed, 0, 100).await.unwrap().attempts,
+        before.attempts
+    );
     log.close().await.unwrap();
 }
diff --git a/crates/event-log/tests/usage.rs b/crates/event-log/tests/usage.rs
index c51e4d83f5..d9da3cf5a4 100644
--- a/crates/event-log/tests/usage.rs
+++ b/crates/event-log/tests/usage.rs
@@ -20,62 +20,17 @@
 use maka_event_log::{
     EventLog,
     sessions::{SessionCopy, SessionCopyResult, SessionRetirement},
-    usage::{Outcome, Query},
+    usage::Outcome,
 };
 use maka_runtime::{
-    context::ModelPurpose,
-    event::{
-        EventWrite, Fact, Invocation, InvocationInput, InvocationOutcome, LogScope,
-        ModelInterruption, RuntimeEvent,
-    },
+    event::{Fact, InvocationInput, InvocationOutcome, ModelInterruption},
     model::{ModelEvent, ModelFinishReason, ModelStep, ModelUsage},
     session::CopyPurpose,
 };
 use serde_json::json;
-use std::time::{Duration, UNIX_EPOCH};
-
-fn event(fact: Fact, micros: u64) -> EventWrite {
-    let mut event = RuntimeEvent::new(
-        Invocation {
-            session_id: "source".into(),
-            turn_id: "turn".into(),
-            run_id: "run".into(),
-            invocation_id: "invocation".into(),
-        },
-        fact,
-    );
-    event.recorded_at = UNIX_EPOCH + Duration::from_micros(micros);
-    EventWrite::plain(event).unwrap()
-}
-
-fn request(id: &str, micros: u64) -> EventWrite {
-    event(
-        Fact::ModelRequested {
-            step_id: id.into(),
-            model_id: "model".into(),
-            purpose: ModelPurpose::Main,
-            source_scope: LogScope::Session {
-                id: "source".into(),
-            },
-            source_high_water: 0,
-            source_digest: "fixture".into(),
-            input_digest: "frozen-input".into(),
-            route_identity: "frozen-route".into(),
-            checkpoint_event_id: None,
-            context: None,
-            effective_source_digest: None,
-        },
-        micros,
-    )
-}
-
-fn query() -> Query {
-    Query {
-        from: 0.0,
-        to: 100.0,
-        session_id: None,
-    }
-}
+#[path = "usage/fixture.rs"]
+mod fixture;
+use fixture::{event, query, request};
 
 #[tokio::test]
 async fn physical_attempt_usage_survives_rejection_copy_removal_and_reopen() {
@@ -153,7 +108,19 @@ async fn physical_attempt_usage_survives_rejection_copy_removal_and_reopen() {
             11500,
         ),
     ];
-    log.append_batch(&writes).await.unwrap();
+    // The second request exists at the fence, but has not settled yet.
+    log.append_batch(&writes[..5]).await.unwrap();
+    let captured = log.model_attempts(query(), 0, 100).await.unwrap();
+    assert_eq!(captured.total, 1);
+    log.append_batch(&writes[5..]).await.unwrap();
+    let mut fixed = query();
+    fixed.through = Some(captured.through);
+    let historical = log.model_attempts(fixed.clone(), 0, 100).await.unwrap();
+    assert_eq!(historical.attempts, captured.attempts);
+    assert_eq!(historical.total, 1);
+    let mut future = query();
+    future.through = Some(u64::MAX);
+    assert!(log.model_attempts(future, 0, 100).await.is_err());
     let terminal = event(
         Fact::InvocationEnded {
             outcome: InvocationOutcome::Completed,
@@ -243,6 +210,14 @@ async fn physical_attempt_usage_survives_rejection_copy_removal_and_reopen() {
     );
     log.close().await.unwrap();
     let reopened = EventLog::open(&path).await.unwrap();
+    assert_eq!(
+        reopened
+            .model_attempts(fixed, 0, 100)
+            .await
+            .unwrap()
+            .attempts,
+        captured.attempts
+    );
     assert_eq!(
         reopened
             .model_attempts(query(), 0, 100)
diff --git a/crates/event-log/tests/usage/fixture.rs b/crates/event-log/tests/usage/fixture.rs
new file mode 100644
index 0000000000..d77d714c99
--- /dev/null
+++ b/crates/event-log/tests/usage/fixture.rs
@@ -0,0 +1,69 @@
+/*
+ * 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::usage::Query;
+use maka_runtime::{
+    context::ModelPurpose,
+    event::{EventWrite, Fact, Invocation, LogScope, RuntimeEvent},
+};
+use std::time::{Duration, UNIX_EPOCH};
+
+pub(super) fn event(fact: Fact, micros: u64) -> EventWrite {
+    let mut event = RuntimeEvent::new(
+        Invocation {
+            session_id: "source".into(),
+            turn_id: "turn".into(),
+            run_id: "run".into(),
+            invocation_id: "invocation".into(),
+        },
+        fact,
+    );
+    event.recorded_at = UNIX_EPOCH + Duration::from_micros(micros);
+    EventWrite::plain(event).unwrap()
+}
+
+pub(super) fn request(id: &str, micros: u64) -> EventWrite {
+    event(
+        Fact::ModelRequested {
+            step_id: id.into(),
+            model_id: "model".into(),
+            purpose: ModelPurpose::Main,
+            source_scope: LogScope::Session {
+                id: "source".into(),
+            },
+            source_high_water: 0,
+            source_digest: "fixture".into(),
+            input_digest: "frozen-input".into(),
+            route_identity: "frozen-route".into(),
+            checkpoint_event_id: None,
+            context: None,
+            effective_source_digest: None,
+        },
+        micros,
+    )
+}
+
+pub(super) fn query() -> Query {
+    Query {
+        from: 0.0,
+        to: 100.0,
+        session_id: None,
+        through: None,
+    }
+}
diff --git a/crates/runtime-host/tests/integration/client_pricing.rs b/crates/runtime-host/tests/integration/client_pricing.rs
index 50b2dfc3ab..1b1c559fa7 100644
--- a/crates/runtime-host/tests/integration/client_pricing.rs
+++ b/crates/runtime-host/tests/integration/client_pricing.rs
@@ -141,6 +141,7 @@ async fn in_flight_model_quotes_are_frozen_and_next_admission_observes_rate_chan
                 from: 0.0,
                 to: f64::MAX,
                 session_id: Some("priced".into()),
+                through: None,
             },
             0,
             100,
diff --git a/crates/runtime-host/tests/integration/javascript_plugins/metering.rs b/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
index 52687ccc30..3cead453b9 100644
--- a/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
+++ b/crates/runtime-host/tests/integration/javascript_plugins/metering.rs
@@ -34,6 +34,7 @@ pub(super) async fn verify(log: &EventLog, expected: usize) {
                 from: 0.0,
                 to: f64::MAX,
                 session_id: Some("js-session".into()),
+                through: None,
             },
             0,
             100,
diff --git a/crates/runtime-host/tests/integration/plugin_authorization.rs b/crates/runtime-host/tests/integration/plugin_authorization.rs
index bb86d71b12..6234fe607a 100644
--- a/crates/runtime-host/tests/integration/plugin_authorization.rs
+++ b/crates/runtime-host/tests/integration/plugin_authorization.rs
@@ -542,6 +542,7 @@ async fn scenario() {
                 from: 0.0,
                 to: f64::MAX,
                 session_id: Some("background-session".into()),
+                through: None,
             },
             0,
             100,


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