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/16 10:34:21 UTC

(maka) branch feat/runtime-host-rust updated (dd151e1d1d -> e3176abde6)

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 dd151e1d1d fix(release): use portable paths for source archive workspaces
     new 84147f94b4 feat(host): complete native update policy and preview distribution
     new e3176abde6 ci: restrict native preview publication to the rewrite branch

The 2 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:
 .github/workflows/native-cli-preview.yml           | 159 +++++++++++++++++++
 apps/desktop/electron-builder.config.mjs           |  22 ++-
 .../src/main/native-runtime-host-command.ts        |   9 ++
 .../src/main/native-runtime-host-installer.ts      |  17 +-
 .../src/main/native-runtime-host-management.ts     |  47 +++++-
 apps/desktop/src/main/native-runtime-host-setup.ts |  16 ++
 apps/desktop/src/main/runtime-host-boot.ts         |  35 ++++-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts | 101 ++++++------
 .../src/main/runtime-host-wsl-controller.ts        |  21 ++-
 .../native-runtime-host-management-dialog.tsx      |  14 +-
 .../src/shared/native-runtime-host-management.ts   |  13 ++
 ...{0001_deployment.sql => 0003_update_policy.sql} |   6 +-
 crates/cli/src/args.rs                             |  10 ++
 crates/cli/src/deployment.rs                       |   5 +
 crates/cli/src/deployment/control.rs               |  12 +-
 crates/cli/src/deployment/package.rs               |  59 ++++++-
 crates/cli/src/deployment/policy.rs                | 168 ++++++++++++++++++++
 crates/cli/src/deployment/policy/storage.rs        |  82 ++++++++++
 crates/cli/src/deployment/policy/worker.rs         | 175 +++++++++++++++++++++
 crates/cli/src/deployment/query.rs                 |   5 +-
 crates/cli/src/deployment/service.rs               |  41 ++++-
 crates/cli/src/deployment/service/linux.rs         |  65 +++++++-
 crates/cli/src/deployment/service/macos.rs         |  26 ++-
 crates/cli/src/deployment/service/status.rs        |  11 +-
 crates/cli/src/deployment/service/windows.rs       |  35 ++++-
 crates/cli/src/deployment/store.rs                 |  36 ++++-
 crates/cli/src/deployment/update.rs                |  66 +++++++-
 crates/cli/src/deployment/updates.rs               |  85 ++++++++--
 crates/cli/src/distribution.rs                     |  73 ++++++++-
 crates/cli/src/distribution/target.rs              |  11 ++
 crates/cli/src/host_client.rs                      |   2 +
 crates/cli/tests/integration/deployment.rs         |  43 +++++
 crates/protocol/src/host.rs                        |  10 +-
 crates/runtime-host/src/server/diagnostics.rs      |   7 +
 crates/runtime-host/src/server/retirement.rs       |  14 +-
 .../runtime-host/tests/integration/retirement.rs   |  19 ++-
 docs/rust-runtime.md                               |  35 +++--
 docs/rust-runtime.zh-CN.md                         |  25 ++-
 package.json                                       |   1 +
 scripts/product-release.test.mjs                   |  16 +-
 scripts/rust/publish-cli.mjs                       | 150 ++++++++++++++++++
 scripts/rust/release-cli.mjs                       |  43 ++++-
 scripts/rust/release-cli.test.mjs                  |  94 +++++++++++
 43 files changed, 1710 insertions(+), 174 deletions(-)
 create mode 100644 .github/workflows/native-cli-preview.yml
 copy crates/cli/migrations/deployment/{0001_deployment.sql => 0003_update_policy.sql} (84%)
 create mode 100644 crates/cli/src/deployment/policy.rs
 create mode 100644 crates/cli/src/deployment/policy/storage.rs
 create mode 100644 crates/cli/src/deployment/policy/worker.rs
 create mode 100644 scripts/rust/publish-cli.mjs


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


(maka) 01/02: feat(host): complete native update policy and preview distribution

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 84147f94b4c5ea9ff0ececefff695f69704c3625
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 18:00:04 2026 +0800

    feat(host): complete native update policy and preview distribution
    
    Bind Desktop to exact native releases, prepare updates before pausing connections, and schedule bounded OS-owned update jobs. Commit schema migrations with pending intent and retain Windows service pairs. Build and validate the three preview platforms from one source archive.
    
    Generated-by: Codex
---
 .github/workflows/native-cli-preview.yml           | 159 +++++++++++++++++++
 apps/desktop/electron-builder.config.mjs           |  22 ++-
 .../src/main/native-runtime-host-command.ts        |   9 ++
 .../src/main/native-runtime-host-installer.ts      |  17 +-
 .../src/main/native-runtime-host-management.ts     |  47 +++++-
 apps/desktop/src/main/native-runtime-host-setup.ts |  16 ++
 apps/desktop/src/main/runtime-host-boot.ts         |  35 ++++-
 apps/desktop/src/main/runtime-host-ssh-terminal.ts | 101 ++++++------
 .../src/main/runtime-host-wsl-controller.ts        |  21 ++-
 .../native-runtime-host-management-dialog.tsx      |  14 +-
 .../src/shared/native-runtime-host-management.ts   |  13 ++
 .../migrations/deployment/0003_update_policy.sql   |  24 +++
 crates/cli/src/args.rs                             |  10 ++
 crates/cli/src/deployment.rs                       |   5 +
 crates/cli/src/deployment/control.rs               |  12 +-
 crates/cli/src/deployment/package.rs               |  59 ++++++-
 crates/cli/src/deployment/policy.rs                | 168 ++++++++++++++++++++
 crates/cli/src/deployment/policy/storage.rs        |  82 ++++++++++
 crates/cli/src/deployment/policy/worker.rs         | 175 +++++++++++++++++++++
 crates/cli/src/deployment/query.rs                 |   5 +-
 crates/cli/src/deployment/service.rs               |  41 ++++-
 crates/cli/src/deployment/service/linux.rs         |  65 +++++++-
 crates/cli/src/deployment/service/macos.rs         |  26 ++-
 crates/cli/src/deployment/service/status.rs        |  11 +-
 crates/cli/src/deployment/service/windows.rs       |  35 ++++-
 crates/cli/src/deployment/store.rs                 |  36 ++++-
 crates/cli/src/deployment/update.rs                |  66 +++++++-
 crates/cli/src/deployment/updates.rs               |  85 ++++++++--
 crates/cli/src/distribution.rs                     |  73 ++++++++-
 crates/cli/src/distribution/target.rs              |  11 ++
 crates/cli/src/host_client.rs                      |   2 +
 crates/cli/tests/integration/deployment.rs         |  43 +++++
 crates/protocol/src/host.rs                        |  10 +-
 crates/runtime-host/src/server/diagnostics.rs      |   7 +
 crates/runtime-host/src/server/retirement.rs       |  14 +-
 .../runtime-host/tests/integration/retirement.rs   |  19 ++-
 docs/rust-runtime.md                               |  35 +++--
 docs/rust-runtime.zh-CN.md                         |  25 ++-
 package.json                                       |   1 +
 scripts/product-release.test.mjs                   |  16 +-
 scripts/rust/publish-cli.mjs                       | 150 ++++++++++++++++++
 scripts/rust/release-cli.mjs                       |  43 ++++-
 scripts/rust/release-cli.test.mjs                  |  94 +++++++++++
 43 files changed, 1731 insertions(+), 171 deletions(-)

diff --git a/.github/workflows/native-cli-preview.yml b/.github/workflows/native-cli-preview.yml
new file mode 100644
index 0000000000..9da32e8d25
--- /dev/null
+++ b/.github/workflows/native-cli-preview.yml
@@ -0,0 +1,159 @@
+# 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.
+
+name: Native CLI preview
+
+on:
+  workflow_dispatch:
+    inputs:
+      publish:
+        description: Publish all three verified packages to rust-preview
+        type: boolean
+        default: false
+
+permissions:
+  contents: read
+
+concurrency:
+  group: native-cli-preview
+  cancel-in-progress: false
+
+jobs:
+  source:
+    runs-on: ubuntu-24.04
+    timeout-minutes: 10
+    outputs:
+      version: ${{ steps.source.outputs.version }}
+      build_id: ${{ steps.source.outputs.build_id }}
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+        with:
+          node-version: '24'
+      - id: source
+        name: Freeze one source archive for every platform
+        run: |
+          node --input-type=module <<'JS'
+          import { readFileSync, appendFileSync } from 'node:fs';
+          import { createSourceCandidate } from './scripts/asf-source-release.mjs';
+          const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
+          await createSourceCandidate({ revision: process.env.GITHUB_SHA, version });
+          appendFileSync(process.env.GITHUB_OUTPUT, 'version=' + version + '\n' +
+            'build_id=' + process.env.GITHUB_RUN_ID + '.' + process.env.GITHUB_RUN_ATTEMPT + '\n');
+          JS
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: native-preview-source
+          path: release/asf/*
+          if-no-files-found: error
+
+  build:
+    needs: source
+    runs-on: ${{ matrix.runner }}
+    timeout-minutes: 60
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - runner: macos-15
+            target: darwin-arm64
+          - runner: ubuntu-24.04
+            target: linux-x64-gnu
+          - runner: windows-2025
+            target: win32-x64
+    env:
+      CARGO_TARGET_DIR: ${{ github.workspace }}/target
+      NATIVE_TARGET: ${{ matrix.target }}
+      SOURCE_VERSION: ${{ needs.source.outputs.version }}
+      BUILD_ID: ${{ needs.source.outputs.build_id }}
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+        with:
+          node-version: '24'
+      - name: Update Rust
+        run: rustup update stable --no-self-update
+      - name: Select the checked-in npm toolchain
+        shell: bash
+        run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')"
+      - name: Install the Linux baseline linker
+        if: runner.os == 'Linux'
+        uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
+        with:
+          version: 0.16.0
+      - name: Install cargo-zigbuild
+        if: runner.os == 'Linux'
+        uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2
+        with:
+          tool: cargo-zigbuild@0.23.4
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: native-preview-source
+          path: source
+      - name: Build verified source and exercise the native executables
+        shell: bash
+        run: |
+          node --input-type=module <<'JS'
+          import { writeFile } from 'node:fs/promises';
+          import { basename } from 'node:path';
+          import { releaseNativeCli } from './scripts/rust/release-cli.mjs';
+          const target = process.env.NATIVE_TARGET;
+          const receipt = await releaseNativeCli({
+            source: 'source/apache-maka-' + process.env.SOURCE_VERSION + '-incubating-src.tar.gz',
+            target, buildId: process.env.BUILD_ID, output: 'native-preview',
+          });
+          await writeFile('native-preview/' + target + '.json',
+            JSON.stringify({ ...receipt, archive: basename(receipt.archive) }) + '\n');
+          JS
+      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: native-preview-${{ matrix.target }}
+          path: native-preview/*
+          if-no-files-found: error
+
+  publish:
+    needs: build
+    if: inputs.publish
+    environment: npm-publication
+    runs-on: ubuntu-24.04
+    timeout-minutes: 15
+    permissions:
+      contents: read
+      id-token: write
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+        with:
+          node-version: '24'
+          registry-url: https://registry.npmjs.org
+      - name: Select the checked-in npm toolchain
+        run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')"
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          pattern: native-preview-*
+          merge-multiple: true
+          path: native-preview
+      - name: Publish the complete source-bound platform set
+        env:
+          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+        run: node scripts/rust/publish-cli.mjs native-preview --publish --provenance
diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs
index 7927055a10..aa4e084e6d 100644
--- a/apps/desktop/electron-builder.config.mjs
+++ b/apps/desktop/electron-builder.config.mjs
@@ -29,6 +29,7 @@ import {
 } from '../../scripts/desktop-nightly.mjs';
 import { workspaceReleaseManifest } from '../../scripts/release-cli-file-policy.mjs';
 import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs';
+import { parseProductReleaseVersion } from '../../scripts/release-version.mjs';
 
 function readManifest(relativePath) {
   return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'));
@@ -48,7 +49,7 @@ function resolvePackageFile(packageName, relativePath) {
   return join(dirname(require.resolve(`${packageName}/package.json`)), relativePath);
 }
 
-async function stageReleaseManifests({ packager, arch }) {
+async function stageNativeAndManifests({ packager, arch }) {
   const target = rustTarget(packager.platform.nodeName, Arch[arch]);
   const binary = await buildCli({
     release: true,
@@ -62,6 +63,10 @@ async function stageReleaseManifests({ packager, arch }) {
   if (packager.platform.nodeName === 'win32') {
     copyFileSync(join(dirname(binary), 'maka-service.exe'), join(nativeStage, 'maka-service.exe'));
   }
+  await stageReleaseManifests({ packager });
+}
+
+export async function stageReleaseManifests({ packager }) {
   const stage = await packager.info.tempDirManager.createTempDir({
     prefix: 'maka-release-manifests',
   });
@@ -77,6 +82,7 @@ async function stageReleaseManifests({ packager, arch }) {
 }
 
 const rootManifest = readManifest('../../package.json');
+const nativeRuntimeHostVersion = parseProductReleaseVersion(rootManifest.nativeRuntimeHostVersion).version;
 const { runtimeHostSetupPackage } = resolveProductManifestIdentity({
   rootManifest,
   desktopManifest: readManifest('./package.json'),
@@ -88,8 +94,8 @@ const baseDesktopBuilderConfig = {
   productName: 'Maka',
   artifactName: 'Maka-${version}-mac-${arch}.${ext}',
   asar: true,
-  beforePack: stageReleaseManifests,
-  extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' },
+  beforePack: stageNativeAndManifests,
+  extraMetadata: { runtimeHostSetupPackage, nativeRuntimeHostVersion, makaUpdateChannel: 'release' },
   directories: {
     output: 'release',
   },
@@ -343,13 +349,17 @@ const baseDesktopBuilderConfig = {
 };
 
 export function resolveDesktopBuilderConfig(environment = process.env) {
+  const nativeVersion = parseProductReleaseVersion(environment.MAKA_NATIVE_CLI_VERSION ?? nativeRuntimeHostVersion).version;
+  const base = { ...baseDesktopBuilderConfig, extraMetadata: {
+    ...baseDesktopBuilderConfig.extraMetadata, nativeRuntimeHostVersion: nativeVersion,
+  } };
   const nightlyVersion = environment.MAKA_DESKTOP_NIGHTLY_VERSION?.trim();
-  if (!nightlyVersion) return baseDesktopBuilderConfig;
+  if (!nightlyVersion) return base;
   const version = resolveDesktopBuildVersion(rootManifest.version, environment);
   return {
-    ...baseDesktopBuilderConfig,
+    ...base,
     extraMetadata: {
-      ...baseDesktopBuilderConfig.extraMetadata,
+      ...base.extraMetadata,
       version,
       runtimeHostSetupPackage: resolveRuntimeHostSetupPackage(rootManifest.version, environment),
       makaUpdateChannel: 'nightly',
diff --git a/apps/desktop/src/main/native-runtime-host-command.ts b/apps/desktop/src/main/native-runtime-host-command.ts
index 81f3131b45..ba532ec982 100644
--- a/apps/desktop/src/main/native-runtime-host-command.ts
+++ b/apps/desktop/src/main/native-runtime-host-command.ts
@@ -34,6 +34,15 @@ export type NativeRuntimeHostOperator =
   | { readonly kind: 'wsl'; readonly distribution: string;
       readonly operator: RuntimeHostNativeOperatorCommand<'posix'> };
 
+/** The path comes from an authenticated deployment receipt, never renderer input. */
+export function nativeOperatorAt(target: NativeRuntimeHostOperator, executable: string): NativeRuntimeHostOperator {
+  switch (target.kind) {
+    case 'local': return { ...target, executable };
+    case 'ssh': return { ...target, operator: { ...target.operator, executablePath: executable } };
+    case 'wsl': return { ...target, operator: { ...target.operator, executablePath: executable } };
+  }
+}
+
 /** Keep draining after an observation timeout; a lost response is not rollback. */
 export function runNativeRuntimeHostCommand(
   target: NativeRuntimeHostOperator,
diff --git a/apps/desktop/src/main/native-runtime-host-installer.ts b/apps/desktop/src/main/native-runtime-host-installer.ts
index cee2501ae5..7581a2c8f6 100644
--- a/apps/desktop/src/main/native-runtime-host-installer.ts
+++ b/apps/desktop/src/main/native-runtime-host-installer.ts
@@ -62,11 +62,10 @@ export interface NativeSetupResult {
 }
 
 /** Transfer and verify code before starting any State Root mutation. */
-export async function installNativeRuntimeHost(
+export async function prepareNativeRuntimeHost(
   transport: NativeSetupTransport,
-  input: NativeSetupInput,
-  onCommit: () => void,
-): Promise<NativeSetupResult> {
+  input: Pick<NativeSetupInput, 'package' | 'signal'>,
+): Promise<RuntimeHostNativeOperatorCommand> {
   input.signal?.throwIfAborted();
   const paths = transport.platform === 'win32' ? win32 : posix;
   const name = `maka-native-${randomUUID().replaceAll('-', '')}`;
@@ -141,7 +140,15 @@ export async function installNativeRuntimeHost(
     });
   }
   input.signal?.throwIfAborted();
-  const operator = nativeArtifactOperator(artifact);
+  return nativeArtifactOperator(artifact);
+}
+
+export async function installNativeRuntimeHost(
+  transport: NativeSetupTransport,
+  input: NativeSetupInput,
+  onCommit: () => void,
+): Promise<NativeSetupResult> {
+  const operator = await prepareNativeRuntimeHost(transport, input);
   // After this point cancellation cannot promise to undo installation. Finish
   // reading its bounded receipt, then let the existing pairing journal take over.
   onCommit();
diff --git a/apps/desktop/src/main/native-runtime-host-management.ts b/apps/desktop/src/main/native-runtime-host-management.ts
index b96c79c6aa..b924c97187 100644
--- a/apps/desktop/src/main/native-runtime-host-management.ts
+++ b/apps/desktop/src/main/native-runtime-host-management.ts
@@ -29,13 +29,14 @@ import {
   nativeRuntimeHostLogsSchema,
   nativeRuntimeHostManagementRequestSchema,
   nativeRuntimeHostMutationSchema,
+  nativeRuntimeHostUpdatePolicySchema,
   type NativeRuntimeHostExpected,
   type NativeRuntimeHostManagementRequest,
   type NativeRuntimeHostManagementResult,
   type NativeRuntimeHostMutation,
   type NativeRuntimeHostSettings,
 } from '../shared/native-runtime-host-management.js';
-import { runNativeRuntimeHostCommand, type NativeRuntimeHostOperator } from './native-runtime-host-command.js';
+import { nativeOperatorAt, runNativeRuntimeHostCommand, type NativeRuntimeHostOperator } from './native-runtime-host-command.js';
 
 export interface NativeRuntimeHostChangeScope {
   /** Retain the pause for stop/uninstall, including an unconfirmed outcome. */
@@ -50,6 +51,7 @@ export function createNativeRuntimeHostManagement(input: {
   readonly operator: NativeRuntimeHostOperator;
   readonly rootId: string;
   readonly rootPath?: string;
+  readonly prepareUpdate?: () => Promise<NativeRuntimeHostOperator>;
   readonly change: <T>(run: (scope: NativeRuntimeHostChangeScope) => Promise<T>) => Promise<T>;
 }) {
   const execute = (args: string[], readOnly = false) => runNativeRuntimeHostCommand(input.operator, args, readOnly);
@@ -92,8 +94,13 @@ export function createNativeRuntimeHostManagement(input: {
     action: 'stop' | 'restart' | 'uninstall' | 'update' | 'reconcile',
     current: NativeRuntimeHostDeployment,
     settings?: NativeRuntimeHostSettings,
+    operator?: NativeRuntimeHostOperator,
   ) => {
-    const raw = await execute([
+    if (!operator) {
+      const latest = await requireCurrent(current);
+      operator = nativeOperatorAt(input.operator, (latest.pendingUpdate ?? latest.deployment).executable);
+    }
+    const raw = await runNativeRuntimeHostCommand(operator, [
       ...rooted(action),
       '--expected-deployment-id', current.deploymentId,
       '--expected-revision', String(current.configRevision),
@@ -104,8 +111,9 @@ export function createNativeRuntimeHostManagement(input: {
     return result;
   };
   const change = async (
-    request: Exclude<NativeRuntimeHostManagementRequest, { action: 'status' | 'logs' }>,
+    request: Exclude<NativeRuntimeHostManagementRequest, { action: 'status' | 'logs' | 'update_policy' | 'set_update_policy' }>,
     scope: NativeRuntimeHostChangeScope,
+    updateOperator?: NativeRuntimeHostOperator,
   ): Promise<NativeRuntimeHostMutation | { kind: 'active_tasks' }> => {
     if (request.action === 'start' || request.action === 'install') {
       let status = await read();
@@ -143,12 +151,14 @@ export function createNativeRuntimeHostManagement(input: {
     const observed = await requireCurrent(request.expected);
     const current = observed.deployment;
     const hostEpoch = observed.host.kind === 'connected' ? observed.host.identity.hostEpoch : undefined;
-    if (request.action === 'update' || request.action === 'reconcile') {
+    if (request.action === 'update' || request.action === 'upgrade' || request.action === 'reconcile') {
       // Stage under native authority while the old Host is still serving.
       scope.resumeOnSettled();
       scope.hold();
-      const staged = await mutate(request.action, current,
-        request.action === 'update' ? request.settings : undefined);
+      const staged = await mutate(request.action === 'upgrade' ? 'update' : request.action, current,
+        request.action === 'update' ? request.settings : undefined,
+        request.action === 'upgrade' ? updateOperator : request.action === 'reconcile' && observed.pendingUpdate
+          ? nativeOperatorAt(input.operator, observed.pendingUpdate.executable) : undefined);
       if (staged.kind === 'ready') {
         return staged;
       }
@@ -156,7 +166,7 @@ export function createNativeRuntimeHostManagement(input: {
         throw new Error('Native Host did not confirm a pending update');
       }
       if (!await retire(scope, current, hostEpoch)) return staged;
-      return mutate('reconcile', current);
+      return mutate('reconcile', current, undefined, nativeOperatorAt(input.operator, staged.target.executable));
     }
 
     if (request.action === 'restart') scope.resumeOnSettled();
@@ -172,8 +182,29 @@ export function createNativeRuntimeHostManagement(input: {
         const logs = nativeRuntimeHostLogsSchema.parse(JSON.parse(await execute(rooted('logs'), true)));
         return { status: await read(), logs };
       }
+      if (request.action === 'update_policy' || request.action === 'set_update_policy') {
+        const observed = request.action === 'set_update_policy' ? await requireCurrent(request.expected) : await read();
+        if (observed.kind !== 'installed') throw new Error('Native Host is not installed');
+        const operator = nativeOperatorAt(input.operator, (observed.pendingUpdate ?? observed.deployment).executable);
+        const args = rooted('update-policy');
+        if (request.action === 'set_update_policy') args.push('--policy', request.policy === 'manual' ? 'manual' : 'rust-preview',
+          '--expected-deployment-id', observed.deployment.deploymentId,
+          '--expected-policy-revision', String(request.expectedPolicyRevision));
+        const value: unknown = JSON.parse(await runNativeRuntimeHostCommand(operator, args, request.action === 'update_policy'));
+        if (request.action === 'update_policy') return { status: await read(), updatePolicy: nativeRuntimeHostUpdatePolicySchema.parse(value) };
+        const saved = z.object({ policy: nativeRuntimeHostUpdatePolicySchema, schedulingError: z.string().nullable() }).strict().parse(value);
+        return { status: await read(), updatePolicy: saved.policy, ...(saved.schedulingError ? { schedulingError: saved.schedulingError } : {}) };
+      }
+      // This runs before change() suspends reconnect or retires launchers.
+      // An offline registry must not delay ordinary Host activation.
+      let updateOperator: NativeRuntimeHostOperator | undefined;
+      if (request.action === 'upgrade') {
+        await requireCurrent(request.expected);
+        if (!input.prepareUpdate) throw new Error('Native package updates are unavailable');
+        updateOperator = await input.prepareUpdate();
+      }
       return input.change(async (scope) => {
-        const outcome = await change(request, scope);
+        const outcome = await change(request, scope, updateOperator);
         return { status: await read(), outcome };
       });
     },
diff --git a/apps/desktop/src/main/native-runtime-host-setup.ts b/apps/desktop/src/main/native-runtime-host-setup.ts
index f47ae586db..e78c2216ca 100644
--- a/apps/desktop/src/main/native-runtime-host-setup.ts
+++ b/apps/desktop/src/main/native-runtime-host-setup.ts
@@ -34,6 +34,22 @@ import {
 import type { RuntimeHostTargetIdentity } from './runtime-host-target.js';
 
 const run = promisify(execFile);
+
+/** The application version and the native package version have independent release channels. */
+export async function nativeRuntimeHostVersion(input: {
+  isPackaged: boolean;
+  appPath: string;
+  environment: NodeJS.ProcessEnv;
+}): Promise<string> {
+  const override = input.isPackaged ? undefined : input.environment.MAKA_NATIVE_CLI_VERSION;
+  const manifest: unknown = override === undefined
+    ? JSON.parse(await readFile(join(input.appPath, ...(input.isPackaged ? [] : ['..', '..']), 'package.json'), 'utf8'))
+    : { nativeRuntimeHostVersion: override };
+  const version = z.object({ nativeRuntimeHostVersion: z.string() }).parse(manifest).nativeRuntimeHostVersion;
+  if (!isProductReleaseVersion(version)) throw new Error('Native CLI requires an exact package version');
+  return version;
+}
+
 export const NATIVE_ARTIFACT_PREFIX = '__MAKA_NATIVE_HOST_ARTIFACT__';
 export const NATIVE_SETUP_PREFIX = '__MAKA_NATIVE_HOST_SETUP__';
 const path = z
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index d6077a24fe..5b060e2513 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -230,8 +230,9 @@ import {
   runDesktopRuntimeHostWslSetup,
   resolveDesktopRuntimeHostWslTarget,
   runNativeRuntimeHostWslSetup,
+  prepareNativeRuntimeHostWslPackage,
 } from './runtime-host-wsl-controller.js';
-import { resolveNativeRuntimeHostPackage } from './native-runtime-host-setup.js';
+import { nativeRuntimeHostVersion, resolveNativeRuntimeHostPackage } from './native-runtime-host-setup.js';
 import {
   createRuntimeHostSetupPackageResolver,
 } from "./runtime-host-setup-package.js";
@@ -752,15 +753,18 @@ const guestSessionMountService = createDesktopGuestSessionMountService({
     await runtimeHostManager.unmountGuest(mountId);
   },
 });
+const resolveNativePackage = async (identity: import('./runtime-host-target.js').RuntimeHostTargetIdentity, signal?: AbortSignal) =>
+  resolveNativeRuntimeHostPackage({
+    executable: nativeHostExecutable, cache: join(userDataDir, 'native-cli'),
+    version: await nativeRuntimeHostVersion({ isPackaged: app.isPackaged, appPath: app.getAppPath(), environment: process.env }),
+    identity, signal, sourceCache: app.isPackaged ? undefined : process.env.MAKA_NATIVE_CLI_PACKAGES,
+  });
 const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({
   ipcMain,
   clientInstanceId: runtimeHostClientInstanceId,
   profiles: runtimeHostProfileService,
   ...(!isE2e ? { nativeSetup: {
-    resolvePackage: (identity: import('./runtime-host-target.js').RuntimeHostTargetIdentity, signal?: AbortSignal) =>
-      resolveNativeRuntimeHostPackage({ executable: nativeHostExecutable, cache: join(userDataDir, 'native-cli'),
-        version: (!app.isPackaged && process.env.MAKA_NATIVE_CLI_VERSION) || app.getVersion(), identity, signal,
-        sourceCache: app.isPackaged ? undefined : process.env.MAKA_NATIVE_CLI_PACKAGES }),
+    resolvePackage: resolveNativePackage,
     ssh: runtimeHostSshTerminal.runNativeSetup,
     wsl: runNativeRuntimeHostWslSetup,
   } } : {}),
@@ -776,6 +780,7 @@ const runtimeHostOnboarding = createDesktopRuntimeHostOnboarding({
 });
 const nativeRuntimeHostManagement = isE2e ? undefined : createNativeRuntimeHostManagement({
   operator: { kind: 'local', executable: nativeHostExecutable },
+  prepareUpdate: async () => ({ kind: 'local', executable: nativeHostExecutable }),
   rootId: startupLocalStorageRoot.rootId,
   rootPath: startupLocalStorageRoot.canonicalPath,
   change: (run) => {
@@ -805,6 +810,26 @@ ipcMain.handle('runtime-host-management:native', async (_event, request: unknown
   }
   return createNativeRuntimeHostManagement({
     operator,
+    prepareUpdate: async () => {
+      if (operator.kind === 'ssh') {
+        const target = { destination: operator.destination, sshPort: operator.sshPort };
+        const identity = await runtimeHostSshTerminal.resolveTargetIdentity(target);
+        const prepared = await runtimeHostSshTerminal.prepareNativePackage({
+          ...target, package: await resolveNativePackage(identity),
+        });
+        return { ...operator, operator: prepared };
+      }
+      if (operator.kind === 'wsl') {
+        const target = { distribution: operator.distribution };
+        const identity = await resolveDesktopRuntimeHostWslTarget(target);
+        const prepared = await prepareNativeRuntimeHostWslPackage({
+          ...target, package: await resolveNativePackage(identity),
+        });
+        if (prepared.platform !== 'posix') throw new Error('WSL requires a POSIX native operator');
+        return { ...operator, operator: { ...prepared, platform: 'posix' } };
+      }
+      return operator;
+    },
     rootId: profile.rootId,
     change: (run) => {
       if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable');
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index ccd52d039f..587294e111 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -63,6 +63,7 @@ import {
   type RuntimeHostPeerMeshManagementFrame,
   type RuntimeHostOperatorCapability,
   type RuntimeHostOperatorCommand,
+  type RuntimeHostNativeOperatorCommand,
   type RuntimeHostOperatorPlatform,
   type RuntimeHostServiceManagementAction,
   type RuntimeHostServiceManagementFrame,
@@ -75,7 +76,7 @@ import type {
   DesktopRuntimeHostSshTerminalSnapshot,
 } from '../preload/bridge-contract.js';
 import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js';
-import { installNativeRuntimeHost, type NativeSetupInput, type NativeSetupResult, type NativeSetupCommand } from './native-runtime-host-installer.js';
+import { installNativeRuntimeHost, prepareNativeRuntimeHost, type NativeSetupTransport, type NativeSetupInput, type NativeSetupResult, type NativeSetupCommand } from './native-runtime-host-installer.js';
 import {
   decodeRuntimeHostTarget,
   posixRuntimeHostTargetProbe,
@@ -283,6 +284,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     onComplete?: (frame: RuntimeHostSetupCompleteFrame) => void,
   ): Promise<RuntimeHostSetupCompleteFrame>;
   runNativeSetup(input: NativeSetupInput & DesktopRuntimeHostSshTargetInput, onCommit: () => void): Promise<NativeSetupResult>;
+  prepareNativePackage(input: NativeSetupInput & DesktopRuntimeHostSshTargetInput): Promise<RuntimeHostNativeOperatorCommand>;
   runServiceManagement(
     input: DesktopRuntimeHostSshManagementInput,
   ): Promise<Exclude<RuntimeHostServiceManagementFrame, { kind: 'progress' }>>;
@@ -623,6 +625,54 @@ export function createDesktopRuntimeHostSshTerminal(input: {
     return frame;
   };
 
+  function nativeTransport(setupInput: NativeSetupInput & DesktopRuntimeHostSshTargetInput): NativeSetupTransport {
+      if (closed) throw new Error('Runtime Host SSH terminal is closed');
+      const destination = normalizeRuntimeHostSshDestination(setupInput.destination);
+      const port = setupInput.sshPort === undefined ? undefined : requireSetupPort(setupInput.sshPort);
+      return {
+        platform: setupInput.package.artifact.target === 'win32-x64' ? 'win32' : 'posix',
+        async execute<T>(command: NativeSetupCommand<T>): Promise<T> {
+          command.signal?.throwIfAborted();
+          let result: T | undefined;
+          let failure: Error | undefined;
+          const filter = createRuntimeHostFramedOutputFilter({
+            prefix: command.prefix, pendingMaxBytes: 64 * 1024, decode: command.decode,
+            label: 'Native SSH setup',
+            onFrame: (frame) => {
+              if (result !== undefined) throw new Error('Native SSH setup returned multiple results');
+              result = frame;
+            },
+            onError: (error) => { failure = error; },
+          });
+          const { process, terminal } = startTerminalProcess('ssh',
+            sshRemoteCommandArgs(destination, port, command.command), filter.push, true);
+          const wait = await waitForTerminalProcess(process, {
+            signal: command.signal, timeoutMs: command.timeoutMs, stopGraceMs: input.processStopGraceMs,
+            onAbort: () => dismissPresentation(terminal),
+          }, input.terminateProcessTree);
+          filter.finish();
+          if (failure) throw failure;
+          if (result === undefined) throw new Error(wait.timedOut
+            ? 'Native SSH setup timed out; its outcome is unknown'
+            : `Native SSH setup exited with code ${String(wait.exit.code)} without a result`);
+          completePresentation(terminal);
+          return result;
+        },
+        async upload(source, target, signal) {
+          signal?.throwIfAborted();
+          const { process, terminal } = startTerminalProcess('scp', [
+            '-r', '-o', 'BatchMode=no', '-o', 'ConnectTimeout=15', '-o', 'ControlMaster=no',
+            '-o', 'ControlPath=none', '-o', 'ClearAllForwardings=yes',
+            ...(port === undefined ? [] : ['-P', String(port)]), source, `${destination}:${target.replaceAll('\\', '/')}`,
+          ], undefined, true);
+          const wait = await waitForTerminalProcess(process, { signal, timeoutMs: SETUP_TIMEOUT_MS,
+            stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal) }, input.terminateProcessTree);
+          if (wait.timedOut || wait.exit.code !== 0) throw new Error('Native CLI upload did not complete');
+          completePresentation(terminal);
+        },
+      };
+  }
+
   return {
     activateSshOperator: async (activationInput) => {
       if (activationInput.interaction !== 'terminal') {
@@ -725,53 +775,8 @@ export function createDesktopRuntimeHostSshTerminal(input: {
         throw new Error('Remote Runtime Host target detection returned no result');
       return windows.identity;
     },
-    runNativeSetup: async (setupInput, onCommit) => {
-      if (closed) throw new Error('Runtime Host SSH terminal is closed');
-      const destination = normalizeRuntimeHostSshDestination(setupInput.destination);
-      const port = setupInput.sshPort === undefined ? undefined : requireSetupPort(setupInput.sshPort);
-      return installNativeRuntimeHost({
-        platform: setupInput.package.artifact.target === 'win32-x64' ? 'win32' : 'posix',
-        async execute<T>(command: NativeSetupCommand<T>): Promise<T> {
-          command.signal?.throwIfAborted();
-          let result: T | undefined;
-          let failure: Error | undefined;
-          const filter = createRuntimeHostFramedOutputFilter({
-            prefix: command.prefix, pendingMaxBytes: 64 * 1024, decode: command.decode,
-            label: 'Native SSH setup',
-            onFrame: (frame) => {
-              if (result !== undefined) throw new Error('Native SSH setup returned multiple results');
-              result = frame;
-            },
-            onError: (error) => { failure = error; },
-          });
-          const { process, terminal } = startTerminalProcess('ssh',
-            sshRemoteCommandArgs(destination, port, command.command), filter.push, true);
-          const wait = await waitForTerminalProcess(process, {
-            signal: command.signal, timeoutMs: command.timeoutMs, stopGraceMs: input.processStopGraceMs,
-            onAbort: () => dismissPresentation(terminal),
-          }, input.terminateProcessTree);
-          filter.finish();
-          if (failure) throw failure;
-          if (result === undefined) throw new Error(wait.timedOut
-            ? 'Native SSH setup timed out; its outcome is unknown'
-            : `Native SSH setup exited with code ${String(wait.exit.code)} without a result`);
-          completePresentation(terminal);
-          return result;
-        },
-        async upload(source, target, signal) {
-          signal?.throwIfAborted();
-          const { process, terminal } = startTerminalProcess('scp', [
-            '-r', '-o', 'BatchMode=no', '-o', 'ConnectTimeout=15', '-o', 'ControlMaster=no',
-            '-o', 'ControlPath=none', '-o', 'ClearAllForwardings=yes',
-            ...(port === undefined ? [] : ['-P', String(port)]), source, `${destination}:${target.replaceAll('\\', '/')}`,
-          ], undefined, true);
-          const wait = await waitForTerminalProcess(process, { signal, timeoutMs: SETUP_TIMEOUT_MS,
-            stopGraceMs: input.processStopGraceMs, onAbort: () => dismissPresentation(terminal) }, input.terminateProcessTree);
-          if (wait.timedOut || wait.exit.code !== 0) throw new Error('Native CLI upload did not complete');
-          completePresentation(terminal);
-        },
-      }, setupInput, onCommit);
-    },
+    runNativeSetup: (setupInput, onCommit) => installNativeRuntimeHost(nativeTransport(setupInput), setupInput, onCommit),
+    prepareNativePackage: (setupInput) => prepareNativeRuntimeHost(nativeTransport(setupInput), setupInput),
     runSetup: async (setupInput, onProgress, onComplete) => {
       if (closed) throw new Error('Runtime Host SSH terminal is closed');
       setupInput.signal?.throwIfAborted();
diff --git a/apps/desktop/src/main/runtime-host-wsl-controller.ts b/apps/desktop/src/main/runtime-host-wsl-controller.ts
index 9cb7bcf773..cdea0be866 100644
--- a/apps/desktop/src/main/runtime-host-wsl-controller.ts
+++ b/apps/desktop/src/main/runtime-host-wsl-controller.ts
@@ -43,7 +43,7 @@ import {
   type RuntimeHostServiceManagementFrame,
 } from '@maka/runtime-host/operator';
 import { createRuntimeHostFramedOutputFilter } from './runtime-host-framed-output.js';
-import { installNativeRuntimeHost, quoteNativePosix, type NativeSetupInput, type NativeSetupResult, type NativeSetupCommand } from './native-runtime-host-installer.js';
+import { installNativeRuntimeHost, prepareNativeRuntimeHost, quoteNativePosix, type NativeSetupTransport, type NativeSetupInput, type NativeSetupResult, type NativeSetupCommand } from './native-runtime-host-installer.js';
 import type { DesktopRuntimeHostSetupPackage } from './runtime-host-setup-package.js';
 import { decodeRuntimeHostTarget, posixRuntimeHostTargetProbe, type RuntimeHostTargetIdentity } from './runtime-host-target.js';
 
@@ -56,7 +56,20 @@ export async function runNativeRuntimeHostWslSetup(
   onCommit: () => void,
   overrides: { readonly processFactory?: RuntimeHostWslProcessFactory; readonly wslExecutable?: string } = {},
 ): Promise<NativeSetupResult> {
-  const distribution = normalizeRuntimeHostWslDistribution(input.distribution);
+  return installNativeRuntimeHost(nativeWslTransport(input.distribution, overrides), input, onCommit);
+}
+
+export async function prepareNativeRuntimeHostWslPackage(
+  input: NativeSetupInput & { readonly distribution: string },
+): Promise<RuntimeHostNativeOperatorCommand> {
+  return prepareNativeRuntimeHost(nativeWslTransport(input.distribution), input);
+}
+
+function nativeWslTransport(
+  name: string,
+  overrides: { readonly processFactory?: RuntimeHostWslProcessFactory; readonly wslExecutable?: string } = {},
+): NativeSetupTransport {
+  const distribution = normalizeRuntimeHostWslDistribution(name);
   const factory = overrides.processFactory ?? spawnWsl;
   const executable = overrides.wslExecutable ?? resolveSystemRuntimeHostWslExecutable();
   const execute = <T>(command: NativeSetupCommand<T>): Promise<T> => {
@@ -68,7 +81,7 @@ export async function runNativeRuntimeHostWslSetup(
       prefix: command.prefix, decode: command.decode, label: 'Native WSL setup', onFrame: (result) => result,
     });
   };
-  return installNativeRuntimeHost({
+  return {
     platform: 'posix', execute,
     async upload(source, destination, signal) {
       const prefix = '__MAKA_NATIVE_HOST_UPLOAD__';
@@ -84,7 +97,7 @@ export async function runNativeRuntimeHostWslSetup(
         },
       });
     },
-  }, input, onCommit);
+  };
 }
 
 export async function resolveDesktopRuntimeHostWslTarget(
diff --git a/apps/desktop/src/renderer/settings/native-runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/native-runtime-host-management-dialog.tsx
index 7a36951a70..868f1df757 100644
--- a/apps/desktop/src/renderer/settings/native-runtime-host-management-dialog.tsx
+++ b/apps/desktop/src/renderer/settings/native-runtime-host-management-dialog.tsx
@@ -54,6 +54,10 @@ export function NativeRuntimeHostManagementDialog(props: {
     save: zh ? '应用配置与当前版本' : 'Apply settings and current version',
     reconcile: zh ? '继续待处理更新' : 'Continue pending update',
     logs: zh ? '日志' : 'Logs',
+    updatePolicy: zh ? '自动更新设置' : 'Automatic updates',
+    manualUpdates: zh ? '关闭自动更新' : 'Disable automatic updates',
+    previewUpdates: zh ? '自动跟随 rust-preview' : 'Follow rust-preview automatically',
+    policySaved: zh ? '策略已保存,但系统调度配置失败:' : 'Policy saved, but scheduling failed: ',
     mode: zh ? '运行方式' : 'Launch policy',
     onDemand: zh ? '按需启动' : 'On demand',
     supervised: zh ? '后台常驻' : 'Account service',
@@ -129,6 +133,8 @@ export function NativeRuntimeHostManagementDialog(props: {
         content={<LayoutContent padding={4}>
           <div className="settingsRuntimeHostManagement">
             {error ? <Banner status="error" title={error} /> : null}
+            {result?.schedulingError ? <Banner status="warning" title={copy.policySaved + result.schedulingError} /> : null}
+            {result?.updatePolicy?.lastError ? <Banner status="warning" title={result.updatePolicy.lastError} /> : null}
             {result?.outcome?.kind === 'active_tasks' ? <Banner status="warning" title={copy.busy} /> : null}
             {status?.kind === 'not_installed' ? <p>{copy.notInstalled}</p> : null}
             {status?.kind === 'incomplete' ? <Banner status="error" title={copy.incomplete} /> : null}
@@ -156,7 +162,13 @@ export function NativeRuntimeHostManagementDialog(props: {
                 {button(copy.start, { action: 'start' })}
                 {button(copy.stop, { action: 'stop', expected })}
                 {button(copy.restart, { action: 'restart', expected })}
-                {button(copy.update, { action: 'update', expected, settings: {} })}
+                {button(copy.update, { action: 'upgrade', expected })}
+                {button(copy.updatePolicy, { action: 'update_policy' })}
+                {result?.updatePolicy ? <>
+                  <p>{result.updatePolicy.policy === 'manual' ? (zh ? '当前:手动更新' : 'Current: manual updates') : 'rust-preview'}</p>
+                  {button(copy.manualUpdates, { action: 'set_update_policy', expected, expectedPolicyRevision: result.updatePolicy.revision, policy: 'manual' })}
+                  {button(copy.previewUpdates, { action: 'set_update_policy', expected, expectedPolicyRevision: result.updatePolicy.revision, policy: 'rust_preview' })}
+                </> : null}
                 <Button variant="secondary" size="sm" label={copy.edit} isDisabled={busy}
                   onClick={() => setEdit({ expected, settings: {
                     mode: deployment.mode, websocket: deployment.websocket,
diff --git a/apps/desktop/src/shared/native-runtime-host-management.ts b/apps/desktop/src/shared/native-runtime-host-management.ts
index 24385965d2..1b50ed3d5d 100644
--- a/apps/desktop/src/shared/native-runtime-host-management.ts
+++ b/apps/desktop/src/shared/native-runtime-host-management.ts
@@ -26,6 +26,13 @@ import {
 } from './native-runtime-host-deployment.js';
 
 const expected = deployment.pick({ deploymentId: true, configRevision: true });
+const policy = z.enum(['manual', 'rust_preview']);
+export const nativeRuntimeHostUpdatePolicySchema = z.object({
+  revision: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
+  policy,
+  nextCheckMs: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
+  lastError: z.string().max(8192).nullable(),
+}).strict();
 const settings = deployment.pick({
   mode: true,
   websocket: true,
@@ -44,6 +51,10 @@ export const nativeRuntimeHostManagementRequestSchema = z.discriminatedUnion('ac
   z.object({ action: z.literal('restart'), expected }).strict(),
   z.object({ action: z.literal('uninstall'), expected }).strict(),
   z.object({ action: z.literal('update'), expected, settings }).strict(),
+  z.object({ action: z.literal('upgrade'), expected }).strict(),
+  z.object({ action: z.literal('update_policy') }).strict(),
+  z.object({ action: z.literal('set_update_policy'), expected,
+    expectedPolicyRevision: nativeRuntimeHostUpdatePolicySchema.shape.revision, policy }).strict(),
   z.object({ action: z.literal('reconcile'), expected }).strict(),
 ]);
 
@@ -84,4 +95,6 @@ export interface NativeRuntimeHostManagementResult {
   readonly status: NativeRuntimeHostDeploymentStatus;
   readonly outcome?: NativeRuntimeHostMutation | { readonly kind: 'active_tasks' };
   readonly logs?: NativeRuntimeHostLogs;
+  readonly updatePolicy?: z.infer<typeof nativeRuntimeHostUpdatePolicySchema>;
+  readonly schedulingError?: string;
 }
diff --git a/crates/cli/migrations/deployment/0003_update_policy.sql b/crates/cli/migrations/deployment/0003_update_policy.sql
new file mode 100644
index 0000000000..80b943adeb
--- /dev/null
+++ b/crates/cli/migrations/deployment/0003_update_policy.sql
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+-- Optional authority; old read-only launchers still use the stable Active row.
+CREATE TABLE deployment_update_policy (
+    singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+    configuration TEXT NOT NULL CHECK (json_valid(configuration) AND length(CAST(configuration AS BLOB)) <= 16384)
+) STRICT;
diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs
index 102e2b7d84..2374028245 100644
--- a/crates/cli/src/args.rs
+++ b/crates/cli/src/args.rs
@@ -64,6 +64,12 @@ enum HostCommand {
     Connect(crate::deployment::Connect),
     /// Update code and optional deployment configuration at a safe boundary.
     Update(crate::deployment::Update),
+    /// Download a verified native release and update at a safe boundary.
+    Upgrade(crate::deployment::Upgrade),
+    /// Observe or configure unattended native preview updates.
+    UpdatePolicy(crate::deployment::UpdatePolicy),
+    #[command(hide = true)]
+    AutoUpdate(crate::deployment::AutoUpdate),
     /// Finish an interrupted deployment update without choosing another target.
     Reconcile(crate::deployment::Expected),
     /// Stop a deployment without changing its configuration or pending update.
@@ -134,6 +140,9 @@ impl Cli {
             Command::Host(HostCommand::Activate(args)) => args.run().await,
             Command::Host(HostCommand::Connect(args)) => args.run().await,
             Command::Host(HostCommand::Update(args)) => args.run().await,
+            Command::Host(HostCommand::Upgrade(args)) => args.run().await,
+            Command::Host(HostCommand::UpdatePolicy(args)) => args.run().await,
+            Command::Host(HostCommand::AutoUpdate(args)) => args.run().await,
             Command::Host(HostCommand::Reconcile(args)) => args.reconcile().await,
             Command::Host(HostCommand::Stop(args)) => {
                 args.run(crate::deployment::ControlAction::Stop).await
@@ -169,6 +178,7 @@ impl Cli {
                         expected_host_epoch.as_deref(),
                         allow_interrupt_active_tasks,
                         handoff_connection_id,
+                        false,
                     )
                     .await?;
                 drop(client);
diff --git a/crates/cli/src/deployment.rs b/crates/cli/src/deployment.rs
index 11ba8d61f6..547480de76 100644
--- a/crates/cli/src/deployment.rs
+++ b/crates/cli/src/deployment.rs
@@ -24,6 +24,7 @@ mod control;
 mod entry;
 mod logs;
 mod package;
+mod policy;
 mod query;
 mod service;
 mod setup;
@@ -35,6 +36,10 @@ pub(super) use connect::Connect;
 pub(super) use control::{Control, ControlAction};
 pub(super) use entry::ServiceRun;
 pub(super) use logs::Logs;
+pub(super) use policy::{
+    Configure as UpdatePolicy,
+    worker::{Reconcile as AutoUpdate, Upgrade},
+};
 pub(super) use query::Status;
 pub(super) use setup::Setup;
 pub(super) use update::{Expected, Update};
diff --git a/crates/cli/src/deployment/control.rs b/crates/cli/src/deployment/control.rs
index f65ce0f3f4..041dc86834 100644
--- a/crates/cli/src/deployment/control.rs
+++ b/crates/cli/src/deployment/control.rs
@@ -92,7 +92,7 @@ impl Control {
         if action != ControlAction::Uninstall {
             current.require_active()?;
         }
-        let Some(owner) = update::retire(&current).await? else {
+        let Some(owner) = update::retire(&current, false).await? else {
             println!(
                 "{}",
                 serde_json::to_string(&Outcome::ActiveTasks {
@@ -117,7 +117,15 @@ impl Control {
             };
             // Once revoked, startup remains denied even if OS cleanup fails.
             // Retain Root data, packages and the tombstone for explicit recovery.
-            let cleanup = match service::remove(deployment.clone(), lease.clone(), owner).await {
+            let cleanup = match async {
+                service::remove(deployment.clone(), lease.clone(), owner).await?;
+                if super::policy::read(&directory).await?.revision != 0 {
+                    service::schedule(deployment.clone(), false, lease.clone()).await?;
+                }
+                Ok::<_, HostError>(())
+            }
+            .await
+            {
                 Ok(()) => Cleanup::Complete,
                 Err(error) => Cleanup::Pending {
                     message: error.to_string().chars().take(2048).collect(),
diff --git a/crates/cli/src/deployment/package.rs b/crates/cli/src/deployment/package.rs
index 0bcca300f7..b267fcea38 100644
--- a/crates/cli/src/deployment/package.rs
+++ b/crates/cli/src/deployment/package.rs
@@ -36,10 +36,7 @@ pub(super) fn source(mode: super::Mode) -> Result<PathBuf, HostError> {
     let current = std::env::current_exe()?.canonicalize()?;
     #[cfg(windows)]
     if mode == super::Mode::Supervised && env!("CARGO_BIN_NAME") != "maka-service" {
-        let service = current
-            .parent()
-            .ok_or("executable directory is missing")?
-            .join("maka-service.exe");
+        let service = service_companion(&current)?;
         if !service.is_file() {
             return Err("Windows services require the sibling maka-service.exe artifact".into());
         }
@@ -51,6 +48,60 @@ pub(super) fn source(mode: super::Mode) -> Result<PathBuf, HostError> {
 }
 
 pub(super) fn stage(directory: &Path, source: &Path) -> Result<(PathBuf, String), HostError> {
+    let staged = stage_one(directory, source)?;
+    #[cfg(windows)]
+    {
+        // Keep the GUI companion paired with this exact console binary, not
+        // whichever Desktop version happens to configure it later. Both names
+        // link immutable content-addressed code; no second mutable manifest.
+        let companion = service_companion(source)?;
+        if companion != source && companion.is_file() {
+            crate::distribution::validate_windows_service(&companion)?;
+            let (service, digest) = stage_one(directory, &companion)?;
+            let alias = staged.0.with_extension("service.exe");
+            match std::fs::hard_link(service, &alias) {
+                Ok(()) => {}
+                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
+                    if stage_one(directory, &alias)?.1 != digest {
+                        return Err("installed Windows service companion differs".into());
+                    }
+                }
+                Err(error) => return Err(error.into()),
+            }
+        }
+    }
+    Ok(staged)
+}
+
+#[cfg(windows)]
+fn service_companion(executable: &Path) -> Result<PathBuf, HostError> {
+    let parent = executable
+        .parent()
+        .ok_or("executable directory is missing")?;
+    if executable
+        .file_name()
+        .and_then(|name| name.to_str())
+        .is_some_and(|name| {
+            name.starts_with("maka-") && name.len() == "maka-".len() + 64 + ".exe".len()
+        })
+    {
+        Ok(executable.with_extension("service.exe"))
+    } else {
+        Ok(parent.join("maka-service.exe"))
+    }
+}
+
+#[cfg(windows)]
+pub(super) fn windowless(executable: &Path) -> Result<PathBuf, HostError> {
+    if crate::distribution::validate_windows_service(executable).is_ok() {
+        return Ok(executable.into());
+    }
+    let companion = service_companion(executable)?;
+    crate::distribution::validate_windows_service(&companion)?;
+    Ok(companion)
+}
+
+fn stage_one(directory: &Path, source: &Path) -> Result<(PathBuf, String), HostError> {
     let packages = directory.join("packages");
     maka_event_log::root::private_directory(&packages)?;
     let mut source = File::open(source)?;
diff --git a/crates/cli/src/deployment/policy.rs b/crates/cli/src/deployment/policy.rs
new file mode 100644
index 0000000000..10392b33db
--- /dev/null
+++ b/crates/cli/src/deployment/policy.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 storage;
+pub(super) mod worker;
+
+use super::{Deployment, RootId, directory, service, store};
+use clap::{Args, ValueEnum};
+use maka_event_log::root::FileLease;
+use maka_runtime_host::server::HostError;
+use serde::{Deserialize, Serialize};
+use std::{path::Path, sync::Arc};
+pub(super) use storage::{read, write};
+use uuid::Uuid;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub(super) enum Policy {
+    #[default]
+    Manual,
+    RustPreview,
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub(super) struct Record {
+    pub revision: u64,
+    pub policy: Policy,
+    pub next_check_ms: i64,
+    pub last_error: Option<String>,
+}
+
+#[derive(Args)]
+pub(crate) struct Configure {
+    #[arg(long)]
+    root_id: RootId,
+    /// Omit to observe the saved policy without mutation.
+    #[arg(long, value_enum)]
+    policy: Option<Policy>,
+    #[arg(long, requires = "policy", required_if_eq_any([("policy", "manual"), ("policy", "rust-preview")]))]
+    expected_policy_revision: Option<u64>,
+    #[arg(long, requires = "policy", required_if_eq_any([("policy", "manual"), ("policy", "rust-preview")]))]
+    expected_deployment_id: Option<Uuid>,
+}
+
+impl Configure {
+    pub async fn run(self) -> Result<(), HostError> {
+        arm_deadline()?;
+        let directory = directory(&self.root_id.0)?;
+        let store::Installation::Installed(current) = store::read(&directory).await? else {
+            return Err("Host deployment is absent or incomplete".into());
+        };
+        current.validate_record(&directory)?;
+        if current.root_id != self.root_id.0 {
+            return Err("deployment root identity differs".into());
+        }
+        let Some(policy) = self.policy else {
+            println!("{}", serde_json::to_string(&read(&directory).await?)?);
+            return Ok(());
+        };
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+        match store::read(&directory).await? {
+            store::Installation::Installed(active) if active == current => {}
+            _ => return Err("deployment changed; query again".into()),
+        }
+        current.require_active()?;
+        if Some(current.deployment_id) != self.expected_deployment_id {
+            return Err("deployment identity changed".into());
+        }
+        let previous = read(&directory).await?;
+        if Some(previous.revision) != self.expected_policy_revision {
+            return Err("update policy changed; query again".into());
+        }
+        if policy != Policy::Manual {
+            // First-generation previews lack this entrypoint. Do not register a
+            // scheduler which can never work; upgrade the active Host first.
+            let mut command = tokio::process::Command::new(&current.executable);
+            command
+                .args(["host", "auto-update", "--help"])
+                .stdin(std::process::Stdio::null())
+                .stdout(std::process::Stdio::null())
+                .stderr(std::process::Stdio::null())
+                .kill_on_drop(true);
+            #[cfg(windows)]
+            command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
+            let status =
+                tokio::time::timeout(std::time::Duration::from_secs(5), command.status()).await??;
+            if !status.success() {
+                return Err(
+                    "upgrade the installed native Host before enabling automatic updates".into(),
+                );
+            }
+        }
+        let mut next = previous.clone();
+        if previous.policy != policy {
+            next.revision = previous
+                .revision
+                .checked_add(1)
+                .filter(|value| *value <= 9_007_199_254_740_991)
+                .ok_or("update policy revision is exhausted")?;
+            next.policy = policy;
+            next.next_check_ms = 0;
+            next.last_error = None;
+            write(&directory, lease.clone(), &current, &previous, &next).await?;
+        }
+        // Authority is already committed. A failed projection is repairable by
+        // repeating the same policy request; it must never be called a rollback.
+        let projection = service::schedule(current, policy != Policy::Manual, lease).await;
+        #[derive(Serialize)]
+        #[serde(rename_all = "camelCase")]
+        struct Result {
+            policy: Record,
+            scheduling_error: Option<String>,
+        }
+        println!(
+            "{}",
+            serde_json::to_string(&Result {
+                policy: next,
+                scheduling_error: projection.err().map(|error| error.to_string()),
+            })?
+        );
+        Ok(())
+    }
+}
+
+pub(super) async fn require_revision(directory: &Path, revision: u64) -> Result<(), HostError> {
+    let record = read(directory).await?;
+    if record.revision != revision || record.policy == Policy::Manual {
+        return Err("automatic update policy changed".into());
+    }
+    Ok(())
+}
+
+pub(super) fn now_ms() -> Result<i64, HostError> {
+    Ok(std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)?
+        .as_millis()
+        .try_into()?)
+}
+
+/// A one-shot maintenance process must not retain the executor forever if an
+/// OS service manager or a blocking database task stops responding. Recovery
+/// reads committed authority; this exit makes no rollback promise.
+fn arm_deadline() -> Result<(), HostError> {
+    std::thread::Builder::new()
+        .name("update-deadline".into())
+        .spawn(|| {
+            std::thread::sleep(std::time::Duration::from_secs(600));
+            std::process::exit(70);
+        })?;
+    Ok(())
+}
diff --git a/crates/cli/src/deployment/policy/storage.rs b/crates/cli/src/deployment/policy/storage.rs
new file mode 100644
index 0000000000..a3b8afac20
--- /dev/null
+++ b/crates/cli/src/deployment/policy/storage.rs
@@ -0,0 +1,82 @@
+/*
+ * 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::{Deployment, Record};
+use crate::deployment::{store, updates};
+use maka_event_log::{StoreError, root::FileLease};
+use maka_runtime_host::server::HostError;
+use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions};
+use std::{path::Path, sync::Arc};
+
+pub(in crate::deployment) async fn read(directory: &Path) -> Result<Record, HostError> {
+    let mut connection = SqliteConnection::connect_with(
+        &SqliteConnectOptions::new()
+            .filename(directory.join("deployment.sqlite"))
+            .read_only(true),
+    )
+    .await?;
+    let result = async {
+        let exists: bool = sqlx::query_scalar(
+            "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='deployment_update_policy')"
+        ).fetch_one(&mut connection).await?;
+        if exists { Ok::<_, HostError>(load(&mut connection).await?) } else { Ok(Record::default()) }
+    }.await;
+    let closed = connection.close().await;
+    let record = result?;
+    closed?;
+    Ok(record)
+}
+
+async fn load(connection: &mut SqliteConnection) -> Result<Record, StoreError> {
+    let value: Option<String> = sqlx::query_scalar(
+        "SELECT configuration FROM deployment_update_policy WHERE singleton = 1",
+    )
+    .fetch_optional(connection)
+    .await?;
+    Ok(value
+        .map(|value| serde_json::from_str(&value))
+        .transpose()?
+        .unwrap_or_default())
+}
+
+pub(in crate::deployment) async fn write(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    deployment: &Deployment,
+    previous: &Record,
+    next: &Record,
+) -> Result<(), HostError> {
+    let (deployment, previous, next) = (deployment.clone(), previous.clone(), next.clone());
+    let connection = store::open_writer(directory, lease, None).await?;
+    let result = connection.run(move |connection| Box::pin(async move {
+        let mut transaction = connection.begin().await?;
+        let (active, _) = updates::load(&mut transaction).await?;
+        if active != deployment || load(&mut transaction).await? != previous {
+            return Err(StoreError::InvalidTransition("deployment or update policy changed".into()))
+        }
+        sqlx::query("INSERT INTO deployment_update_policy(singleton, configuration) VALUES (1, ?) ON CONFLICT(singleton) DO UPDATE SET configuration = excluded.configuration")
+            .bind(serde_json::to_string(&next)?).execute(&mut *transaction).await?;
+        transaction.commit().await?;
+        Ok(())
+    })).await;
+    let closed = connection.close().await;
+    result?;
+    closed?;
+    Ok(())
+}
diff --git a/crates/cli/src/deployment/policy/worker.rs b/crates/cli/src/deployment/policy/worker.rs
new file mode 100644
index 0000000000..0f2e7a5364
--- /dev/null
+++ b/crates/cli/src/deployment/policy/worker.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::{Policy, Record, now_ms, read, write};
+use crate::{
+    deployment::{self, Admission, Deployment, RootId, directory, query, store, update::Action},
+    distribution,
+};
+use clap::Args;
+use maka_event_log::root::FileLease;
+use maka_runtime_host::server::HostError;
+use semver::Version;
+use std::{path::Path, process::Stdio, sync::Arc};
+
+#[derive(Args)]
+pub(crate) struct Reconcile {
+    #[arg(long)]
+    root_id: RootId,
+}
+
+impl Reconcile {
+    pub async fn run(self) -> Result<(), HostError> {
+        super::arm_deadline()?;
+        let directory = directory(&self.root_id.0)?;
+        let store::Installation::Installed(current) = store::read(&directory).await? else {
+            return Ok(());
+        };
+        current.validate_record(&directory)?;
+        if current.root_id != self.root_id.0 {
+            return Err("deployment root identity differs".into());
+        }
+        if current.admission != Admission::Active {
+            return Ok(());
+        }
+        // Pending can already carry a newer schema while Active still serves
+        // old code. Select its executor before any policy writer or migration.
+        let pending = query::pending(&directory, &current).await?;
+        let executor = pending.as_ref().unwrap_or(&current).executable.clone();
+        if std::env::current_exe()?.canonicalize()? != executor {
+            return execute(
+                &executor,
+                &[
+                    "host".into(),
+                    "auto-update".into(),
+                    "--root-id".into(),
+                    current.root_id,
+                ],
+            )
+            .await;
+        }
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+        let previous = read(&directory).await?;
+        if previous.policy == Policy::Manual || previous.next_check_ms > now_ms()? {
+            return Ok(());
+        }
+        let mut claim = previous.clone();
+        claim.next_check_ms = now_ms()? + 10 * 60 * 1000;
+        claim.last_error = None;
+        write(&directory, lease.clone(), &current, &previous, &claim).await?;
+        drop(lease);
+        // Neither the executor nor Root is held across any network request.
+        let result = perform(&current, pending, &claim).await;
+        let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+        let store::Installation::Installed(active) = store::read(&directory).await? else {
+            return Ok(());
+        };
+        // The new executable may have migrated policy storage. Its next
+        // scheduled invocation owns completion bookkeeping; an old writer
+        // must not open the newer schema after successful handoff.
+        if active.executable != executor
+            || query::pending(&directory, &active)
+                .await?
+                .is_some_and(|pending| pending.executable != executor)
+        {
+            return Ok(());
+        }
+        let previous = read(&directory).await?;
+        if active.deployment_id != current.deployment_id || previous != claim {
+            return Ok(());
+        }
+        let mut next = previous.clone();
+        match result {
+            Ok(()) => {
+                if query::pending(&directory, &active).await?.is_none() {
+                    next.next_check_ms = now_ms()? + 60 * 60 * 1000;
+                }
+            }
+            Err(error) => next.last_error = Some(error.to_string().chars().take(2048).collect()),
+        }
+        write(&directory, lease, &active, &previous, &next).await
+    }
+}
+
+async fn perform(
+    current: &Deployment,
+    pending: Option<Deployment>,
+    claim: &Record,
+) -> Result<(), HostError> {
+    if let Some(target) = pending {
+        // A frozen intent wins over channel changes and failed download retries.
+        execute(
+            &target.executable,
+            &deployment::Expected::from_deployment(current, claim.revision)
+                .arguments(Action::Reconcile),
+        )
+        .await?;
+        return Ok(());
+    }
+    let version = distribution::preview_version().await?;
+    let artifact = distribution::fetch(version).await?;
+    execute(
+        &artifact.executable,
+        &deployment::Expected::from_deployment(current, claim.revision).arguments(Action::Update),
+    )
+    .await?;
+    Ok(())
+}
+
+pub(in crate::deployment) async fn execute(
+    executable: &Path,
+    args: &[String],
+) -> Result<(), HostError> {
+    let mut command = tokio::process::Command::new(executable);
+    command.args(args).stdin(Stdio::null());
+    #[cfg(windows)]
+    command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
+    let status = command.status().await?;
+    if !status.success() {
+        return Err(format!("native update executor failed: {status}").into());
+    }
+    Ok(())
+}
+
+#[derive(Args)]
+pub(crate) struct Upgrade {
+    #[command(flatten)]
+    expected: deployment::Expected,
+    /// Exact native package version. Omit to resolve rust-preview once.
+    #[arg(long)]
+    version: Option<Version>,
+}
+
+impl Upgrade {
+    pub async fn run(self) -> Result<(), HostError> {
+        super::arm_deadline()?;
+        let version = match self.version {
+            Some(version) => version,
+            None => distribution::preview_version().await?,
+        };
+        let artifact = distribution::fetch(version).await?;
+        // The selected distribution writes its own configuration and stages the
+        // correct Windows console/service executable; arbitrary --source is absent.
+        execute(
+            &artifact.executable,
+            &self.expected.arguments(Action::Update),
+        )
+        .await
+    }
+}
diff --git a/crates/cli/src/deployment/query.rs b/crates/cli/src/deployment/query.rs
index b82ef74c77..9a2c742278 100644
--- a/crates/cli/src/deployment/query.rs
+++ b/crates/cli/src/deployment/query.rs
@@ -151,7 +151,10 @@ async fn observe_host(deployment: &Deployment) -> HostObservation {
     })
 }
 
-async fn pending(directory: &Path, current: &Deployment) -> Result<Option<Deployment>, HostError> {
+pub(super) async fn pending(
+    directory: &Path,
+    current: &Deployment,
+) -> Result<Option<Deployment>, HostError> {
     // Earlier installed operators have only the Active table. Observation must
     // not run migrations or create a pending table on their behalf.
     let mut connection = SqliteConnection::connect_with(
diff --git a/crates/cli/src/deployment/service.rs b/crates/cli/src/deployment/service.rs
index 8d1f194f84..ea7e08c479 100644
--- a/crates/cli/src/deployment/service.rs
+++ b/crates/cli/src/deployment/service.rs
@@ -22,6 +22,12 @@ use maka_event_log::root::{FileLease, RootOwner};
 use maka_runtime_host::server::HostError;
 use std::sync::Arc;
 
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Role {
+    Host,
+    Updater,
+}
+
 mod status;
 use status::State;
 pub(super) use status::{Observation, observe};
@@ -52,7 +58,7 @@ pub(super) async fn start(
         if owner.root_id() != deployment.root_id {
             return Err("State Root changed before service activation".into());
         }
-        let service = platform::Service::new(&deployment)?;
+        let service = platform::Service::new(&deployment, Role::Host)?;
         // Root is held: an OS restart cannot begin user work during replacement.
         service.prepare()?;
         owner.validate_current()?;
@@ -95,7 +101,7 @@ async fn change(
         if owner.root_id() != deployment.root_id || owner.canonical_path() != deployment.root_path {
             return Err("State Root changed before service control".into());
         }
-        let service = platform::Service::new(&deployment)?;
+        let service = platform::Service::new(&deployment, Role::Host)?;
         if remove {
             service.remove()?;
         } else {
@@ -108,7 +114,7 @@ async fn change(
     .await?
 }
 
-fn arguments(deployment: &Deployment) -> Result<[&str; 5], HostError> {
+fn arguments(deployment: &Deployment, role: Role) -> Result<[&str; 5], HostError> {
     let executable = deployment
         .executable
         .to_str()
@@ -119,14 +125,37 @@ fn arguments(deployment: &Deployment) -> Result<[&str; 5], HostError> {
     Ok([
         executable,
         "host",
-        "service-run",
+        match role {
+            Role::Host => "service-run",
+            Role::Updater => "auto-update",
+        },
         "--root-id",
         &deployment.root_id,
     ])
 }
 
-fn label(deployment: &Deployment) -> String {
-    format!("org.apache.maka.host.{}", deployment.root_id)
+fn label(deployment: &Deployment, role: Role) -> String {
+    let suffix = if role == Role::Updater { ".update" } else { "" };
+    format!("org.apache.maka.host.{}{suffix}", deployment.root_id)
+}
+
+pub(super) async fn schedule(
+    deployment: Deployment,
+    enabled: bool,
+    lease: Arc<FileLease>,
+) -> Result<(), HostError> {
+    tokio::task::spawn_blocking(move || {
+        lease.validate()?;
+        let service = platform::Service::new(&deployment, Role::Updater)?;
+        if enabled {
+            service.prepare()?
+        } else {
+            service.remove()?
+        }
+        lease.validate()?;
+        Ok::<_, HostError>(())
+    })
+    .await?
 }
 
 #[cfg(any(target_os = "macos", windows))]
diff --git a/crates/cli/src/deployment/service/linux.rs b/crates/cli/src/deployment/service/linux.rs
index 1a3ff594b0..2809241341 100644
--- a/crates/cli/src/deployment/service/linux.rs
+++ b/crates/cli/src/deployment/service/linux.rs
@@ -17,18 +17,19 @@
  * under the License.
  */
 
-use super::{Deployment, HostError, arguments, checked, command, label, publish};
+use super::{Deployment, HostError, Role, arguments, checked, command, label, publish};
 use std::path::PathBuf;
 
 pub(super) struct Service {
     unit: String,
     path: PathBuf,
     definition: String,
+    role: Role,
 }
 
 impl Service {
-    pub fn new(deployment: &Deployment) -> Result<Self, HostError> {
-        let unit = format!("{}.service", label(deployment));
+    pub fn new(deployment: &Deployment, role: Role) -> Result<Self, HostError> {
+        let unit = format!("{}.service", label(deployment, role));
         let home = crate::serve::home_directory()?.ok_or("missing account home")?;
         let config = std::env::var_os("XDG_CONFIG_HOME")
             .filter(|path| !path.is_empty())
@@ -37,18 +38,45 @@ impl Service {
         if !config.is_absolute() {
             return Err("XDG_CONFIG_HOME must be absolute".into());
         }
-        let executable = arguments(deployment)?.map(quote).join(" ");
-        let definition = format!(
-            "[Unit]\nDescription=Maka Host\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nExecStart=:{executable}\nRestart=on-failure\nRestartSec=2\nKillMode=mixed\nTimeoutStopSec=45\nUMask=0077\n\n[Install]\nWantedBy=default.target\n"
-        );
+        let executable = arguments(deployment, role)?.map(quote).join(" ");
+        let definition = if role == Role::Updater {
+            format!(
+                "[Unit]\nDescription=Maka native update\n\n[Service]\nType=oneshot\nExecStart=:{executable}\nTimeoutStartSec=600\nTimeoutStopSec=45\nUMask=0077\n"
+            )
+        } else {
+            format!(
+                "[Unit]\nDescription=Maka Host\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nExecStart=:{executable}\nRestart=on-failure\nRestartSec=2\nKillMode=mixed\nTimeoutStopSec=45\nUMask=0077\n\n[Install]\nWantedBy=default.target\n"
+            )
+        };
         Ok(Self {
             path: config.join("systemd/user").join(&unit),
             unit,
             definition,
+            role,
         })
     }
 
     pub fn prepare(&self) -> Result<(), HostError> {
+        if self.role == Role::Updater {
+            self.stop()?;
+            publish(&self.path, &self.definition)?;
+            let timer = self.path.with_extension("timer");
+            publish(
+                &timer,
+                "[Unit]\nDescription=Maka native update schedule\n\n[Timer]\nOnStartupSec=2min\nOnUnitInactiveSec=10min\n\n[Install]\nWantedBy=timers.target\n",
+            )?;
+            checked(command("systemctl", &["--user", "daemon-reload"])?)?;
+            checked(command(
+                "systemctl",
+                &[
+                    "--user",
+                    "enable",
+                    "--now",
+                    &self.unit.replace(".service", ".timer"),
+                ],
+            )?)?;
+            return Ok(());
+        }
         // An SSH logout must not destroy a persistent user service.
         let uid = unsafe { libc::geteuid() }.to_string();
         if checked(command(
@@ -92,6 +120,25 @@ impl Service {
     }
 
     pub fn remove(&self) -> Result<(), HostError> {
+        if self.role == Role::Updater {
+            let timer = self.path.with_extension("timer");
+            match timer.symlink_metadata() {
+                Ok(metadata) if metadata.is_file() => {
+                    checked(command(
+                        "systemctl",
+                        &[
+                            "--user",
+                            "disable",
+                            "--now",
+                            &self.unit.replace(".service", ".timer"),
+                        ],
+                    )?)?;
+                    std::fs::remove_file(timer)?;
+                }
+                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+                _ => return Err("update timer is not a regular file".into()),
+            }
+        }
         let fragment = checked(command(
             "systemctl",
             &[
@@ -108,7 +155,9 @@ impl Service {
         self.stop()?;
         match self.path.symlink_metadata() {
             Ok(metadata) if metadata.is_file() => {
-                checked(command("systemctl", &["--user", "disable", &self.unit])?)?;
+                if self.role == Role::Host {
+                    checked(command("systemctl", &["--user", "disable", &self.unit])?)?;
+                }
                 std::fs::remove_file(&self.path)?;
                 std::fs::File::open(self.path.parent().ok_or("service path has no parent")?)?
                     .sync_all()?;
diff --git a/crates/cli/src/deployment/service/macos.rs b/crates/cli/src/deployment/service/macos.rs
index 60f564ffe7..cfe7aed21b 100644
--- a/crates/cli/src/deployment/service/macos.rs
+++ b/crates/cli/src/deployment/service/macos.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{Deployment, HostError, arguments, checked, command, label, publish, xml};
+use super::{Deployment, HostError, Role, arguments, checked, command, label, publish, xml};
 use std::{
     path::PathBuf,
     time::{Duration, Instant},
@@ -37,21 +37,31 @@ struct Instance {
 }
 
 impl Service {
-    pub fn new(deployment: &Deployment) -> Result<Self, HostError> {
+    pub fn new(deployment: &Deployment, role: Role) -> Result<Self, HostError> {
         let uid = unsafe { libc::geteuid() }.to_string();
-        let label = label(deployment);
+        let label = label(deployment, role);
         let home = crate::serve::home_directory()?.ok_or("missing account home")?;
         let path = home
             .join("Library/LaunchAgents")
             .join(format!("{label}.plist"));
-        let args = arguments(deployment)?
+        let args = arguments(deployment, role)?
             .map(|arg| format!("<string>{}</string>", xml(arg)))
             .join("");
-        let log = super::super::directory(&deployment.root_id)?.join("host.stderr.log");
+        let log = super::super::directory(&deployment.root_id)?.join(if role == Role::Host {
+            "host.stderr.log"
+        } else {
+            "update.stderr.log"
+        });
         let log = xml(log.to_str().ok_or("service log path must be UTF-8")?);
-        let definition = format!(
-            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>Label</key><string>{label}</string><key>ProgramArguments</key><array>{args}</array><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>2</integer><key>ExitTimeOut</key><integer>45</integer><key>Umask</key><integer>63</integer><key>StandardErrorPath</key><string>{log}</string></dict></plist>\n"
-        );
+        let definition = if role == Role::Updater {
+            format!(
+                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>Label</key><string>{label}</string><key>ProgramArguments</key><array>{args}</array><key>StartInterval</key><integer>600</integer><key>RunAtLoad</key><true/><key>Umask</key><integer>63</integer><key>StandardErrorPath</key><string>{log}</string></dict></plist>\n"
+            )
+        } else {
+            format!(
+                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\"><dict><key>Label</key><string>{label}</string><key>ProgramArguments</key><array>{args}</array><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>2</integer><key>ExitTimeOut</key><integer>45</integer><key>Umask</key><integer>63</integer><key>StandardErrorPath</key><string>{log}</string></dict></plist>\n"
+            )
+        };
         Ok(Self {
             target: format!("gui/{uid}/{label}"),
             uid,
diff --git a/crates/cli/src/deployment/service/status.rs b/crates/cli/src/deployment/service/status.rs
index 870911b360..6461f8dc82 100644
--- a/crates/cli/src/deployment/service/status.rs
+++ b/crates/cli/src/deployment/service/status.rs
@@ -55,11 +55,12 @@ pub(crate) async fn observe(deployment: Deployment) -> Observation {
     if deployment.mode == super::super::Mode::OnDemand {
         return Observation::OnDemand;
     }
-    let result =
-        tokio::task::spawn_blocking(move || platform::Service::new(&deployment)?.observe())
-            .await
-            .map_err(HostError::from)
-            .and_then(|result| result);
+    let result = tokio::task::spawn_blocking(move || {
+        platform::Service::new(&deployment, super::Role::Host)?.observe()
+    })
+    .await
+    .map_err(HostError::from)
+    .and_then(|result| result);
     match result {
         Ok(observed) => observed,
         Err(error) => Observation::Unavailable {
diff --git a/crates/cli/src/deployment/service/windows.rs b/crates/cli/src/deployment/service/windows.rs
index 7075a4c235..fc63b8a0c0 100644
--- a/crates/cli/src/deployment/service/windows.rs
+++ b/crates/cli/src/deployment/service/windows.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{Deployment, HostError, arguments, label, xml};
+use super::{Deployment, HostError, Role, arguments, label, xml};
 use std::time::{Duration, Instant};
 use windows::{
     Win32::{
@@ -49,7 +49,7 @@ pub(super) struct Service {
 }
 
 impl Service {
-    pub fn new(deployment: &Deployment) -> Result<Self, HostError> {
+    pub fn new(deployment: &Deployment, role: Role) -> Result<Self, HostError> {
         let apartment = Apartment::open()?;
         // SAFETY: initialization and every interface stay on this blocking worker.
         let (scheduler, folder, user) = unsafe {
@@ -67,21 +67,40 @@ impl Service {
             };
             (scheduler, folder, user)
         };
-        let name = label(deployment);
-        let args = arguments(deployment)?;
-        let executable = xml(args[0]);
+        let name = label(deployment, role);
+        let args = arguments(deployment, role)?;
+        let executable = if role == Role::Updater {
+            let windowless = super::super::package::windowless(&deployment.executable)?;
+            xml(windowless
+                .to_str()
+                .ok_or("service executable must be UTF-8")?)
+        } else {
+            xml(args[0])
+        };
         let args = xml(&args[1..]
             .iter()
             .map(|arg| quote(arg))
             .collect::<Vec<_>>()
             .join(" "));
         let principal = xml(&user);
+        let trigger = if role == Role::Updater {
+            "<CalendarTrigger><Repetition><Interval>PT10M</Interval><Duration>P1D</Duration><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>2000-01-01T00:00:00</StartBoundary><Enabled>true</Enabled><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger>".to_owned()
+        } else {
+            format!(
+                "<LogonTrigger><Enabled>true</Enabled><UserId>{principal}</UserId></LogonTrigger>"
+            )
+        };
+        let lifetime = if role == Role::Updater {
+            "<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>"
+        } else {
+            "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>5</Count></RestartOnFailure>"
+        };
         let definition = format!(
             concat!(
                 "<?xml version=\"1.0\" encoding=\"UTF-16\"?>",
                 "<Task version=\"1.4\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">",
                 "<RegistrationInfo><Description>{name}</Description></RegistrationInfo>",
-                "<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{principal}</UserId></LogonTrigger></Triggers>",
+                "<Triggers>{trigger}</Triggers>",
                 "<Principals><Principal id=\"Maka\"><UserId>{principal}</UserId><LogonType>InteractiveToken</LogonType>",
                 "<RunLevel>LeastPrivilege</RunLevel></Principal></Principals>",
                 "<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>",
@@ -90,11 +109,13 @@ impl Service {
                 "<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>",
                 "<IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>",
                 "<AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled>",
-                "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><RestartOnFailure><Interval>PT1M</Interval><Count>5</Count></RestartOnFailure></Settings>",
+                "{lifetime}</Settings>",
                 "<Actions Context=\"Maka\"><Exec><Command>{executable}</Command><Arguments>{args}</Arguments></Exec></Actions></Task>"
             ),
             name = name,
             principal = principal,
+            trigger = trigger,
+            lifetime = lifetime,
             executable = executable,
             args = args
         );
diff --git a/crates/cli/src/deployment/store.rs b/crates/cli/src/deployment/store.rs
index c67eb9e280..aca1b9b0bd 100644
--- a/crates/cli/src/deployment/store.rs
+++ b/crates/cli/src/deployment/store.rs
@@ -111,6 +111,22 @@ pub(super) async fn open_writer(
     directory: &Path,
     lease: Arc<FileLease>,
     root: Option<Arc<RootOwner>>,
+) -> Result<OwnedConnection, HostError> {
+    open(directory, lease, root, true).await
+}
+
+pub(super) async fn open_update_writer(
+    directory: &Path,
+    lease: Arc<FileLease>,
+) -> Result<OwnedConnection, HostError> {
+    open(directory, lease, None, false).await
+}
+
+async fn open(
+    directory: &Path,
+    lease: Arc<FileLease>,
+    root: Option<Arc<RootOwner>>,
+    migrate_on_open: bool,
 ) -> Result<OwnedConnection, HostError> {
     let path = directory.join(DATABASE);
     let file = private_file(&path)?;
@@ -122,7 +138,16 @@ pub(super) async fn open_writer(
             .filename(&path)
             .create_if_missing(false),
         ConnectionAuthority::Writer { lease, root },
-        |connection| Box::pin(initialize(connection)),
+        if migrate_on_open {
+            |connection| {
+                Box::pin(async move {
+                    initialize(connection).await?;
+                    migrate(connection).await
+                })
+            }
+        } else {
+            |connection| Box::pin(initialize(connection))
+        },
     )
     .await?)
 }
@@ -185,6 +210,11 @@ pub(super) async fn change(
                 sqlx::query("DELETE FROM deployment_update WHERE singleton = 1")
                     .execute(&mut *transaction)
                     .await?;
+                // A revoked or replaced deployment cannot inherit an automatic
+                // update grant. Retain the row so cleanup retries know a
+                // scheduler may still exist.
+                sqlx::query("UPDATE deployment_update_policy SET configuration = json_set(configuration, '$.policy', 'manual', '$.nextCheckMs', 0, '$.lastError', NULL) WHERE singleton = 1")
+                    .execute(&mut *transaction).await?;
                 transaction.commit().await?;
                 Ok(target)
             })
@@ -267,6 +297,10 @@ async fn initialize(connection: &mut SqliteConnection) -> Result<(), StoreError>
     )
     .execute(&mut *connection)
     .await?;
+    Ok(())
+}
+
+pub(super) async fn migrate(connection: &mut SqliteConnection) -> Result<(), StoreError> {
     MIGRATIONS.run_direct(None, connection, false).await?;
     Ok(())
 }
diff --git a/crates/cli/src/deployment/update.rs b/crates/cli/src/deployment/update.rs
index b0b4ca62a4..b9fc3aafa2 100644
--- a/crates/cli/src/deployment/update.rs
+++ b/crates/cli/src/deployment/update.rs
@@ -45,6 +45,9 @@ pub(crate) struct Expected {
     expected_deployment_id: Uuid,
     #[arg(long, value_parser = clap::value_parser!(u64).range(1..=9_007_199_254_740_991))]
     expected_revision: u64,
+    /// Guard a scheduled update against a concurrently changed policy.
+    #[arg(long, hide = true)]
+    expected_policy_revision: Option<u64>,
 }
 
 #[derive(Args)]
@@ -59,9 +62,17 @@ struct Settings {
     directories: configuration::Directories,
 }
 
+pub(super) enum Action {
+    Update,
+    Reconcile,
+}
+
 #[derive(Serialize)]
 #[serde(tag = "kind", rename_all = "snake_case")]
 enum Outcome {
+    Applied {
+        deployment: Deployment,
+    },
     Ready {
         deployment: Deployment,
         host: LiveHost,
@@ -79,6 +90,35 @@ impl Update {
 }
 
 impl Expected {
+    pub(super) fn from_deployment(deployment: &Deployment, policy_revision: u64) -> Self {
+        Self {
+            root_id: RootId(deployment.root_id.clone()),
+            expected_deployment_id: deployment.deployment_id,
+            expected_revision: deployment.config_revision,
+            expected_policy_revision: Some(policy_revision),
+        }
+    }
+
+    pub(super) fn arguments(&self, action: Action) -> Vec<String> {
+        let mut args = vec![
+            "host".into(),
+            match action {
+                Action::Update => "update",
+                Action::Reconcile => "reconcile",
+            }
+            .into(),
+            "--root-id".into(),
+            self.root_id.0.clone(),
+            "--expected-deployment-id".into(),
+            self.expected_deployment_id.to_string(),
+            "--expected-revision".into(),
+            self.expected_revision.to_string(),
+        ];
+        if let Some(revision) = self.expected_policy_revision {
+            args.extend(["--expected-policy-revision".into(), revision.to_string()]);
+        }
+        args
+    }
     pub async fn reconcile(self) -> Result<(), HostError> {
         self.run(None).await
     }
@@ -89,6 +129,9 @@ impl Expected {
             return Err("Host deployment is not installed".into());
         }
         let lease = Arc::new(FileLease::acquire(&directory.join("executor.lock"))?);
+        if let Some(revision) = self.expected_policy_revision {
+            super::policy::require_revision(&directory, revision).await?;
+        }
         // Do not create a deployment database through update/reconcile.
         let store::Installation::Installed(current) = store::read(&directory).await? else {
             return Err("Host deployment is absent or incomplete".into());
@@ -154,7 +197,8 @@ impl Expected {
         };
         let deployment = if let Some(target) = target {
             target.validate(&root, &directory)?;
-            let Some(owner) = retire(&current).await? else {
+            let Some(owner) = retire(&current, self.expected_policy_revision.is_some()).await?
+            else {
                 lease.validate()?;
                 println!(
                     "{}",
@@ -177,6 +221,16 @@ impl Expected {
         } else {
             current
         };
+        if self.expected_policy_revision.is_some() && deployment.mode == Mode::OnDemand {
+            // A scheduled updater must not parent an on-demand Host: systemd
+            // would reap it with the updater's cgroup. Attached clients reconnect
+            // through their ordinary launcher; a sleeping Host stays asleep.
+            println!(
+                "{}",
+                serde_json::to_string(&Outcome::Applied { deployment })?
+            );
+            return Ok(());
+        }
         // The executor is already held. Calling the public activate command here
         // would acquire it twice. A Ready failure never restores older code.
         let (client, host) = activation::connect_or_launch(&deployment, lease.clone()).await?;
@@ -191,7 +245,10 @@ impl Expected {
 }
 
 /// A prepared receipt is not proof of release: only acquiring RootOwner is.
-pub(super) async fn retire(deployment: &Deployment) -> Result<Option<RootOwner>, HostError> {
+pub(super) async fn retire(
+    deployment: &Deployment,
+    allow_idle_connections: bool,
+) -> Result<Option<RootOwner>, HostError> {
     tokio::time::timeout(Duration::from_secs(30), async {
         loop {
             match RootOwner::open(
@@ -212,7 +269,10 @@ pub(super) async fn retire(deployment: &Deployment) -> Result<Option<RootOwner>,
             if let Ok(mut client) =
                 HostClient::connect(&deployment.root_path, Some(&deployment.generation())).await
             {
-                match client.retire(None, false, None).await? {
+                match client
+                    .retire(None, false, None, allow_idle_connections)
+                    .await?
+                {
                     RetirementResult::ActiveTasks => return Ok(None),
                     RetirementResult::Prepared { .. } => {}
                 }
diff --git a/crates/cli/src/deployment/updates.rs b/crates/cli/src/deployment/updates.rs
index 9aa9a88816..f723c6cfb4 100644
--- a/crates/cli/src/deployment/updates.rs
+++ b/crates/cli/src/deployment/updates.rs
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-use super::{Deployment, store};
+use super::{Deployment, query, store};
 use maka_event_log::{
     StoreError,
     root::{FileLease, RootOwner},
@@ -31,14 +31,13 @@ pub(super) async fn read(
     directory: &Path,
     lease: Arc<FileLease>,
 ) -> Result<(Deployment, Option<Deployment>), HostError> {
-    let connection = store::open_writer(directory, lease, None).await?;
-    let result = connection
-        .run(|connection| Box::pin(load(connection)))
-        .await;
-    let closed = connection.close().await;
-    let state = result?;
-    closed?;
-    Ok(state)
+    lease.validate()?;
+    let store::Installation::Installed(active) = store::read(directory).await? else {
+        return Err("Host deployment is absent or incomplete".into());
+    };
+    let pending = query::pending(directory, &active).await?;
+    lease.validate()?;
+    Ok((active, pending))
 }
 
 pub(super) async fn prepare(
@@ -48,9 +47,13 @@ pub(super) async fn prepare(
     target: Deployment,
 ) -> Result<(), HostError> {
     validate_target(&expected, &target)?;
-    let connection = store::open_writer(directory, lease, None).await?;
+    let connection = store::open_update_writer(directory, lease).await?;
     let result = connection.run(move |connection| Box::pin(async move {
         let mut transaction = connection.begin().await?;
+        // Never strand the old executor on a newer schema without a durable
+        // pointer to the executable which can recover it. SQLite migrations
+        // use nested savepoints inside this same transaction.
+        store::migrate(&mut transaction).await?;
         let (active, pending) = load(&mut transaction).await?;
         if active != expected || pending.as_ref().is_some_and(|pending| pending != &target) {
             return Err(conflict());
@@ -154,7 +157,7 @@ fn conflict() -> StoreError {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::deployment::{Mode, package};
+    use crate::deployment::{Mode, package, policy};
     use maka_event_log::root::{self, RootNamespaces};
 
     #[tokio::test]
@@ -186,9 +189,62 @@ mod tests {
             .unwrap();
         let mut target = current.clone();
         target.config_revision += 1;
+        target.project_directory_roots = Some(Vec::new());
+        let writer = store::open_writer(&directory, lease.clone(), None)
+            .await
+            .unwrap();
+        writer.run(|connection| Box::pin(async move {
+            // Model the published predecessor and a failure after migration.
+            sqlx::raw_sql("DROP TABLE deployment_update_policy; DELETE FROM _sqlx_migrations WHERE version = 3; CREATE TRIGGER reject_prepare BEFORE INSERT ON deployment_update BEGIN SELECT RAISE(ABORT, 'prepare failed'); END;")
+                .execute(connection).await?;
+            Ok(())
+        })).await.unwrap();
+        writer.close().await.unwrap();
+        assert!(
+            prepare(&directory, lease.clone(), current.clone(), target.clone())
+                .await
+                .is_err()
+        );
+        let writer = store::open_update_writer(&directory, lease.clone())
+            .await
+            .unwrap();
+        writer.run(|connection| Box::pin(async move {
+            let migrated: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM _sqlx_migrations WHERE version = 3)")
+                .fetch_one(&mut *connection).await?;
+            let table: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'deployment_update_policy')")
+                .fetch_one(&mut *connection).await?;
+            assert!(!migrated && !table, "failed preparation must also roll back migrations");
+            sqlx::query("DROP TRIGGER reject_prepare").execute(connection).await?;
+            Ok(())
+        })).await.unwrap();
+        writer.close().await.unwrap();
+        assert_eq!(
+            read(&directory, lease.clone()).await.unwrap(),
+            (current.clone(), None)
+        );
+        prepare(&directory, lease.clone(), current.clone(), target.clone())
+            .await
+            .unwrap();
+        let manual = policy::read(&directory).await.unwrap();
+        assert_eq!(manual.policy, policy::Policy::Manual);
+        let automatic = policy::Record {
+            policy: policy::Policy::RustPreview,
+            revision: 1,
+            ..manual.clone()
+        };
+        policy::write(&directory, lease.clone(), &current, &manual, &automatic)
+            .await
+            .unwrap();
+        assert_eq!(policy::read(&directory).await.unwrap(), automatic);
+        assert!(
+            policy::write(&directory, lease.clone(), &current, &manual, &automatic)
+                .await
+                .is_err()
+        );
+        assert!(policy::require_revision(&directory, 0).await.is_err());
+        policy::require_revision(&directory, 1).await.unwrap();
         // A configuration-only target has the same executable, but still
         // requires the exact pending receipt and writer-held atomic cut.
-        target.project_directory_roots = Some(Vec::new());
         prepare(&directory, lease.clone(), current.clone(), target.clone())
             .await
             .unwrap();
@@ -283,6 +339,11 @@ mod tests {
         .unwrap();
         assert_eq!(revoked.config_revision, 3);
         assert!(revoked.require_active().is_err());
+        assert_eq!(
+            policy::read(&directory).await.unwrap().policy,
+            policy::Policy::Manual
+        );
+        assert!(policy::require_revision(&directory, 1).await.is_err());
         assert_eq!(
             read(&directory, lease.clone()).await.unwrap(),
             (revoked.clone(), None)
diff --git a/crates/cli/src/distribution.rs b/crates/cli/src/distribution.rs
index a69a9e6903..f23654aba6 100644
--- a/crates/cli/src/distribution.rs
+++ b/crates/cli/src/distribution.rs
@@ -83,11 +83,11 @@ struct Distribution {
 
 #[derive(Serialize)]
 #[serde(rename_all = "camelCase")]
-struct Artifact {
+pub(crate) struct Artifact {
     target: Target,
     version: String,
     directory: PathBuf,
-    executable: PathBuf,
+    pub(crate) executable: PathBuf,
     #[serde(skip_serializing_if = "Option::is_none")]
     service_executable: Option<PathBuf>,
     integrity: String,
@@ -98,11 +98,7 @@ impl Fetch {
         let framed = self.framed;
         // Honor the CLI environment's HTTP(S)/ALL_PROXY and NO_PROXY. No npm,
         // lifecycle scripts, registry credentials, or running Host are involved.
-        let client = Client::builder()
-            .connect_timeout(Duration::from_secs(15))
-            .timeout(Duration::from_secs(120))
-            .redirect(reqwest::redirect::Policy::none())
-            .build()?;
+        let client = client()?;
         let artifact = tokio::time::timeout(
             Duration::from_secs(180),
             self.resolve(client, Url::parse(REGISTRY)?),
@@ -295,6 +291,69 @@ impl Fetch {
     }
 }
 
+fn client() -> Result<Client, HostError> {
+    Ok(Client::builder()
+        .connect_timeout(Duration::from_secs(15))
+        .timeout(Duration::from_secs(120))
+        .redirect(reqwest::redirect::Policy::none())
+        .build()?)
+}
+
+#[cfg(windows)]
+pub(crate) fn validate_windows_service(path: &Path) -> Result<(), HostError> {
+    Target::Win32X64.validate_binary(&mut regular_file(path, MAX_ARCHIVE_BYTES)?, true)
+}
+
+/// Resolve the preview channel once; subsequent retries use this exact version.
+pub(crate) async fn preview_version() -> Result<Version, HostError> {
+    #[derive(Deserialize)]
+    struct Tags {
+        #[serde(rename = "rust-preview")]
+        preview: String,
+    }
+    let name = Target::current()?.package_name().replace('/', "%2f");
+    let mut response = client()?
+        .get(format!("{REGISTRY}-/package/{name}/dist-tags"))
+        .send()
+        .await?
+        .error_for_status()?;
+    if !response.status().is_success() {
+        return Err("native package registry redirect is not allowed".into());
+    }
+    let mut bytes = Vec::new();
+    while let Some(chunk) = response.chunk().await? {
+        if bytes.len().saturating_add(chunk.len()) > MAX_METADATA_BYTES {
+            return Err("native package metadata exceeds the size limit".into());
+        }
+        bytes.extend_from_slice(&chunk);
+    }
+    let tags: Tags = serde_json::from_slice(&bytes)?;
+    let version = Version::parse(&tags.preview)?;
+    if !version.pre.as_str().starts_with("rust-preview.") || !version.build.is_empty() {
+        return Err("rust-preview points outside the native preview channel".into());
+    }
+    Ok(version)
+}
+
+pub(crate) async fn fetch(version: Version) -> Result<Artifact, HostError> {
+    let fetch = Fetch {
+        target: Target::current()?,
+        version,
+        cache: None,
+        archive: None,
+        integrity: None,
+        directory: None,
+        receipt_sha256: None,
+        framed: false,
+    };
+    tokio::time::timeout(
+        Duration::from_secs(180),
+        fetch.resolve(client()?, Url::parse(REGISTRY)?),
+    )
+    .await
+    .map_err(|_| "native package download timed out")?
+}
+
 fn receipt_digest(value: &str) -> Result<String, &'static str> {
     if value.len() == 64
         && value
diff --git a/crates/cli/src/distribution/target.rs b/crates/cli/src/distribution/target.rs
index 4672b5320c..72873fbd08 100644
--- a/crates/cli/src/distribution/target.rs
+++ b/crates/cli/src/distribution/target.rs
@@ -36,6 +36,17 @@ pub(super) enum Target {
 }
 
 impl Target {
+    pub fn current() -> Result<Self, HostError> {
+        match (std::env::consts::OS, std::env::consts::ARCH) {
+            ("macos", "aarch64") => Ok(Self::DarwinArm64),
+            ("macos", "x86_64") => Ok(Self::DarwinX64),
+            ("linux", "aarch64") => Ok(Self::LinuxArm64Gnu),
+            ("linux", "x86_64") => Ok(Self::LinuxX64Gnu),
+            ("windows", "x86_64") => Ok(Self::Win32X64),
+            _ => Err("native CLI distribution is unavailable for this platform".into()),
+        }
+    }
+
     pub fn slug(self) -> &'static str {
         match self {
             Self::DarwinArm64 => "darwin-arm64",
diff --git a/crates/cli/src/host_client.rs b/crates/cli/src/host_client.rs
index 396c6db286..808d52f919 100644
--- a/crates/cli/src/host_client.rs
+++ b/crates/cli/src/host_client.rs
@@ -186,6 +186,7 @@ impl HostClient {
         expected_epoch: Option<&str>,
         interrupt: bool,
         handoff_connection_id: Option<uuid::Uuid>,
+        allow_idle_connections: bool,
     ) -> Result<RetirementResult, HostError> {
         if expected_epoch.is_some_and(|epoch| epoch != self.discovery.host_epoch) {
             return Err("Host epoch does not match the retirement target".into());
@@ -194,6 +195,7 @@ impl HostClient {
             expected_host_epoch: self.discovery.host_epoch.clone(),
             allow_interrupt_active_tasks: interrupt,
             allow_cooperative_handoff: Some(true),
+            allow_idle_connections: allow_idle_connections.then_some(true),
             handoff_connection_id: handoff_connection_id.map(|id| id.to_string()),
         };
         let result = decode_retirement_result(
diff --git a/crates/cli/tests/integration/deployment.rs b/crates/cli/tests/integration/deployment.rs
index f6989bd739..6131fc0b69 100644
--- a/crates/cli/tests/integration/deployment.rs
+++ b/crates/cli/tests/integration/deployment.rs
@@ -73,6 +73,49 @@ fn managed_installation_pins_code_before_migration_and_preserves_live_authority(
         let executable = installed["executable"].as_str().unwrap();
         assert_eq!(installed["rootId"], fixture.root_id);
         assert_eq!(installed["configRevision"], 1);
+        // A fresh installation is offline/manual. A stale policy request and a
+        // scheduled executor must not create intent, start Host, or access npm.
+        assert_eq!(query("update-policy")["policy"], "manual");
+        assert_eq!(query("update-policy")["revision"], 0);
+        let automatic = Command::new(executable)
+            .args(["host", "auto-update", "--root-id", &fixture.root_id])
+            .output()
+            .unwrap();
+        assert!(automatic.status.success(), "{automatic:?}");
+        let guarded = Command::new(executable)
+            .args([
+                "host",
+                "update",
+                "--root-id",
+                &fixture.root_id,
+                "--expected-deployment-id",
+                installed["deploymentId"].as_str().unwrap(),
+                "--expected-revision",
+                "1",
+                "--expected-policy-revision",
+                "0",
+            ])
+            .output()
+            .unwrap();
+        assert!(!guarded.status.success());
+        assert!(String::from_utf8_lossy(&guarded.stderr).contains("policy changed"));
+        let stale = Command::new(executable)
+            .args([
+                "host",
+                "update-policy",
+                "--root-id",
+                &fixture.root_id,
+                "--policy",
+                "rust-preview",
+                "--expected-policy-revision",
+                "99",
+                "--expected-deployment-id",
+                installed["deploymentId"].as_str().unwrap(),
+            ])
+            .output()
+            .unwrap();
+        assert!(!stale.status.success());
+        assert!(String::from_utf8_lossy(&stale.stderr).contains("policy changed"));
         let owner = RootOwner::open(&fixture.root, &namespaces).unwrap();
         let observed = query("status");
         assert_eq!(observed["deployment"], installed);
diff --git a/crates/protocol/src/host.rs b/crates/protocol/src/host.rs
index 7e5e5b3de2..2396690704 100644
--- a/crates/protocol/src/host.rs
+++ b/crates/protocol/src/host.rs
@@ -32,6 +32,9 @@ pub struct RetirementInput {
     pub allow_interrupt_active_tasks: bool,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub allow_cooperative_handoff: Option<bool>,
+    /// Permit idle clients to reconnect, without interrupting owned work.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub allow_idle_connections: Option<bool>,
     /// A trusted operator may coordinate retirement with this still-connected client.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub handoff_connection_id: Option<String>,
@@ -48,7 +51,11 @@ pub fn decode_retirement_input(value: &Value) -> Result<RetirementInput> {
     codec::shaped(
         codec::record(value, "Host retirement")?,
         &["expectedHostEpoch", "allowInterruptActiveTasks"],
-        &["allowCooperativeHandoff", "handoffConnectionId"],
+        &[
+            "allowCooperativeHandoff",
+            "handoffConnectionId",
+            "allowIdleConnections",
+        ],
     )?;
     let boolean = |value: &Value| {
         value
@@ -58,6 +65,7 @@ pub fn decode_retirement_input(value: &Value) -> Result<RetirementInput> {
     Ok(RetirementInput {
         expected_host_epoch: codec::string(&value["expectedHostEpoch"], "Host epoch", 128)?,
         allow_interrupt_active_tasks: boolean(&value["allowInterruptActiveTasks"])?,
+        allow_idle_connections: value.get("allowIdleConnections").map(boolean).transpose()?,
         allow_cooperative_handoff: value
             .get("allowCooperativeHandoff")
             .map(boolean)
diff --git a/crates/runtime-host/src/server/diagnostics.rs b/crates/runtime-host/src/server/diagnostics.rs
index 0c6a261e8c..39a6805af0 100644
--- a/crates/runtime-host/src/server/diagnostics.rs
+++ b/crates/runtime-host/src/server/diagnostics.rs
@@ -37,6 +37,13 @@ impl Activity {
         self.connections > 1 || self.commands > own_commands || self.resident_count() != 0
     }
 
+    pub(super) fn allowing_idle_connections(mut self, allow: bool) -> Self {
+        if allow {
+            self.connections = 0;
+        }
+        self
+    }
+
     pub(super) fn resident_count(&self) -> usize {
         self.executions + self.shells + self.oauth
     }
diff --git a/crates/runtime-host/src/server/retirement.rs b/crates/runtime-host/src/server/retirement.rs
index 1be2d494d6..04278e33e3 100644
--- a/crates/runtime-host/src/server/retirement.rs
+++ b/crates/runtime-host/src/server/retirement.rs
@@ -90,7 +90,9 @@ impl Host {
                     "Handoff connection is no longer attached",
                 ));
             }
-            let activity = self.activity_except(handoff_connection);
+            let activity = self
+                .activity_except(handoff_connection)
+                .allowing_idle_connections(input.allow_idle_connections == Some(true));
             if input.allow_interrupt_active_tasks || !activity.blocks_retirement(1) {
                 *phase = Phase::Retiring;
                 self.draining.cancel();
@@ -106,13 +108,19 @@ impl Host {
             }
         };
         drop(admission);
-        self.cooperate(preparation, handoff_connection).await
+        self.cooperate(
+            preparation,
+            handoff_connection,
+            input.allow_idle_connections == Some(true),
+        )
+        .await
     }
 
     async fn cooperate(
         &self,
         mut preparation: Preparation<'_>,
         handoff_connection: Option<uuid::Uuid>,
+        allow_idle_connections: bool,
     ) -> Result<RetirementResult, OperationError> {
         let handoff_id = uuid::Uuid::new_v4().to_string();
         let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
@@ -123,6 +131,7 @@ impl Host {
             if self.draining.is_cancelled()
                 || self
                     .activity_except(handoff_connection)
+                    .allowing_idle_connections(allow_idle_connections)
                     .blocks_cooperation(1)
             {
                 return Ok(RetirementResult::ActiveTasks);
@@ -166,6 +175,7 @@ impl Host {
                     if self.draining.is_cancelled()
                         || self
                             .activity_except(handoff_connection)
+                            .allowing_idle_connections(allow_idle_connections)
                             .blocks_cooperation(1)
                     {
                         return Ok(RetirementResult::ActiveTasks);
diff --git a/crates/runtime-host/tests/integration/retirement.rs b/crates/runtime-host/tests/integration/retirement.rs
index 072018f6fb..9cdde7e8f4 100644
--- a/crates/runtime-host/tests/integration/retirement.rs
+++ b/crates/runtime-host/tests/integration/retirement.rs
@@ -201,8 +201,13 @@ async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_aba
             receive(&mut responses).await["result"]["kind"],
             "active_tasks"
         );
-        drop(unrelated);
-        unrelated_task.await.unwrap().unwrap();
+        let idle_client = if fail {
+            Some((unrelated, unrelated_task))
+        } else {
+            drop(unrelated);
+            unrelated_task.await.unwrap().unwrap();
+            None
+        };
         input["handoffConnectionId"] = uuid::Uuid::new_v4().to_string().into();
         requests
             .send(json!({"requestId":"stale", "operation":"host.upgrade.prepare", "input":input}))
@@ -212,6 +217,12 @@ async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_aba
             "operation_conflict"
         );
         input["handoffConnectionId"] = desktop_identity["connectionId"].clone();
+        if fail {
+            // Scheduled updates may reconnect idle clients without pretending
+            // they own those clients' handoff IDs or permission to interrupt work.
+            input.as_object_mut().unwrap().remove("handoffConnectionId");
+            input["allowIdleConnections"] = true.into();
+        }
         requests
             .send(
                 json!({"requestId":"retire", "operation":"host.upgrade.prepare",
@@ -255,6 +266,10 @@ async fn retirement_fences_admission_and_keeps_root_until_receipt_flushed_or_aba
         assert_eq!(connection.await.unwrap().is_err(), fail);
         drop(desktop);
         desktop_task.await.unwrap().unwrap();
+        if let Some((client, task)) = idle_client {
+            drop(client);
+            task.await.unwrap().unwrap();
+        }
         drop(host);
         tokio::time::timeout(Duration::from_secs(5), server)
             .await
diff --git a/docs/rust-runtime.md b/docs/rust-runtime.md
index 8bb9b30293..40062ba0dc 100644
--- a/docs/rust-runtime.md
+++ b/docs/rust-runtime.md
@@ -102,9 +102,11 @@ uses `cargo zigbuild` with an explicit `x86_64-unknown-linux-gnu.2.28` or
 `aarch64-unknown-linux-gnu.2.28` target; install cargo-zigbuild and Zig on the build machine.
 Development builds still use ordinary Cargo. SSH/WSL onboarding rejects older glibc before downloading.
 
-Desktop SSH/WSL onboarding downloads and verifies the exact Desktop version locally, transfers
-the complete package, removes the upload staging directory, and sets up the native Host.
-The target needs no Node/npm/Rust. Native npm releases are not published yet; development builds
+Desktop SSH/WSL onboarding downloads and verifies its `nativeRuntimeHostVersion` pin locally,
+transfers the complete package, removes upload staging, and sets up the native Host.
+The pin is an exact npm version, independent of the Desktop version; packaging can select it
+with `MAKA_NATIVE_CLI_VERSION`. Published previews cover macOS arm64, Linux x64 and Windows x64.
+The target needs no Node/npm/Rust. Development builds
 can set `MAKA_NATIVE_CLI_VERSION` and `MAKA_NATIVE_CLI_PACKAGES` (a `host fetch` cache directory).
 These overrides are ignored in packaged Desktop. Existing profiles start offline.
 
@@ -137,7 +139,18 @@ same identity arguments finishes the recorded update. A committed target is neve
 automatically rolled back, even if startup fails. Supervised activation replaces
 the service definition only while holding the Root. Upgrades target recoverable
 restarts, not uninterrupted sockets or PTYs; no separate control daemon is planned.
-Unattended release selection is not yet connected.
+
+`host upgrade` takes the same identity arguments, downloads `rust-preview` (or an exact
+`--version`) before handoff, and delegates the update to that package. Desktop also prepares
+downloads and SSH/WSL transfers before pausing connections.
+`host update-policy --root-id <rootId>` reads the automatic-update policy. To change it, add
+`--policy rust-preview|manual --expected-policy-revision <revision> --expected-deployment-id <id>`.
+The default is manual. Automatic updates use an independent OS timer/task, not a resident daemon;
+checks succeed at hourly intervals, with ten-minute retries for failures or busy work.
+Idle clients reconnect after a switch; running work, PTYs and OAuth defer it.
+On-demand Hosts remain asleep until a client activates them. Disabling the policy fences queued
+updates; uninstall also removes the task. `lastError` reports attempt failures and
+`schedulingError` reports a saved policy whose OS task needs repair by repeating the request.
 
 `host stop`, `host restart` and `host uninstall` take the same identity arguments.
 Stop and restart preserve pending updates. Uninstall revokes startup before removing
@@ -255,8 +268,12 @@ Use the same identifier for all platforms of one build and a new identifier for
 publication; CI may use `<run-id>.<attempt>`. Identifiers follow SemVer prerelease rules.
 `makaSource` records the source archive name, source version and SHA-512.
 This is traceability, not a signed build attestation.
-Omit `--keys` only for unsigned local candidates. No command publishes to npm;
-the channel remains `rust-preview`. `CARGO_TARGET_DIR` may retain build caches,
+Omit `--keys` only for unsigned local candidates. Native builds validate `--version` and V8 execution;
+`--validator` is only required for cross-target packaging. Notices default to the source's Rust inventory.
+The `Native CLI preview` workflow builds all three platforms from one frozen source archive.
+`node scripts/rust/publish-cli.mjs <artifact-directory>` validates their common provenance;
+`--publish` publishes the complete verified set to `rust-preview` (CI supplies npm provenance).
+`CARGO_TARGET_DIR` may retain build caches,
 but `MAKA_JS_DEPS` is fixed to the extracted source's own install.
 
 `node scripts/rust/pack-cli.mjs --target <target> --version <exact-version>
@@ -326,7 +343,7 @@ so WorkHub's “Needs you” view agrees with candidate discovery and clears aft
 Skill mutations, update previews and their transaction recovery remain incomplete.
 Session branching, removal, import/export and recap, along with additional runtime
 policy settings, also remain incomplete.
-Orchestration, some capability services, managed upgrades and other protocol domains also remain incomplete. Full Desktop
-acceptance and release packaging across Linux, macOS and Windows are still
-required. Plugin implementation follows Agent Graph; OS sandboxing is deferred. Memory is excluded pending a
+Orchestration, some capability services and other protocol domains remain incomplete;
+native deployment and updates do not imply full product compatibility.
+Plugin implementation follows Agent Graph; OS sandboxing is deferred. Memory is excluded pending a
 separate redesign; its existing implementation is not ported. Content redaction is omitted.
diff --git a/docs/rust-runtime.zh-CN.md b/docs/rust-runtime.zh-CN.md
index e808f1fd1f..e0d111c9c4 100644
--- a/docs/rust-runtime.zh-CN.md
+++ b/docs/rust-runtime.zh-CN.md
@@ -86,8 +86,10 @@ Linux 发布支持 glibc 2.28 及以上。`node scripts/rust/build-cli.mjs --rel
 `cargo zigbuild`,显式指定 `x86_64-unknown-linux-gnu.2.28` 或 `aarch64-unknown-linux-gnu.2.28`;
 构建机器须安装 cargo-zigbuild 和 Zig。开发构建仍使用普通 Cargo,SSH/WSL 引导在下载前拒绝旧版 glibc。
 
-Desktop SSH/WSL 引导在本机下载并验证与 Desktop 版本一致的完整包,再传输、清理上传暂存目录并设置原生 Host。
-目标无需 Node/npm/Rust。原生 npm 包尚未发布;开发构建可设置 `MAKA_NATIVE_CLI_VERSION` 和
+Desktop SSH/WSL 引导在本机下载并验证 `nativeRuntimeHostVersion` 固定的完整包,再传输、清理暂存目录并设置原生 Host。
+该字段是独立于 Desktop 版本的精确 npm 版本,打包时可通过 `MAKA_NATIVE_CLI_VERSION` 指定。
+已发布的 preview 覆盖 macOS arm64、Linux x64、Windows x64。
+目标无需 Node/npm/Rust。开发构建可设置 `MAKA_NATIVE_CLI_VERSION` 和
 `MAKA_NATIVE_CLI_PACKAGES`(由 `host fetch` 填充的缓存目录),正式打包的 Desktop 忽略这些覆盖。
 已有配置的启动无需访问 npm。
 
@@ -110,7 +112,15 @@ Windows 托管服务使用同目录的 `maka-service.exe`,它是同一 Host 
 活跃客户端或不可交接任务会推迟切换;随后用相同身份参数运行 `host reconcile` 完成已记录的更新。
 目标一旦提交,即使启动失败也不自动回退。Supervised 激活仅在持有 Root 时替换服务定义。
 升级以可恢复的短暂重启为目标,不保证 socket 或 PTY 连续存活,不计划增加独立控制进程。
-无人值守版本选择尚未接通。
+
+`host upgrade` 使用相同身份参数,先下载 `rust-preview`(或 `--version` 指定的精确版本),再委派该版本完成更新。
+Desktop 同样先完成下载和 SSH/WSL 传输,再暂停连接。
+`host update-policy --root-id <rootId>` 查询自动更新策略;修改时附加
+`--policy rust-preview|manual --expected-policy-revision <revision> --expected-deployment-id <id>`。
+默认手动更新。自动更新使用独立 OS 定时任务,不增加常驻进程;成功后每小时检查,失败或工作繁忙时每十分钟重试。
+空闲客户端在切换后重连,执行中的任务、PTY 和 OAuth 会推迟切换;按需 Host 不被更新任务唤醒。
+关闭策略会阻止已排队的自动更新,卸载同时删除定时任务。`lastError` 报告尝试失败;
+`schedulingError` 表示策略已保存但 OS 任务未就绪,可重复同一请求修复。
 
 `host stop`、`host restart`、`host uninstall` 使用相同的身份参数。
 停止和重启保留待更新目标。卸载先撤销启动资格,再注销服务;Root 数据、代码包及部署撤销记录均保留。
@@ -198,7 +208,11 @@ Client Capability 注册与反向调用所有权位于 `client-capability`,Hos
 同一构建的全部平台使用相同标识,每次发布使用新标识;CI 可使用 `<run-id>.<attempt>`。
 标识遵循 SemVer 预发布规则。包内 `makaSource` 记录源码归档名、源码版本与 SHA-512,
 用于溯源,不是签名构建证明。
-仅本地未签名候选可省略 `--keys`。命令不发布 npm,通道仍为 `rust-preview`。
+仅本地未签名候选可省略 `--keys`。本机构建会验证版本和 V8 执行;仅跨平台打包必须指定 `--validator`,
+许可证清单默认取自源码中的 Rust 依赖清单。
+`Native CLI preview` workflow 用同一份冻结源码构建三个平台。
+`node scripts/rust/publish-cli.mjs <产物目录>` 校验三者的共同来源;
+附加 `--publish` 才会发布完整产物集到 `rust-preview`,CI 提供 npm provenance。
 可用 `CARGO_TARGET_DIR` 保留构建缓存;`MAKA_JS_DEPS` 固定为解包源码自身的安装目录。
 
 `node scripts/rust/pack-cli.mjs --target <目标> --version <精确版本>
@@ -254,6 +268,5 @@ WorkHub 已支持受限对话、候选发现、交互式目标选择、向已有
 
 Skills 变更、更新预览及其事务恢复仍未完成。
 会话分支、删除、导入导出、回顾及其它 runtime policy 设置也未完成。
-编排、部分 capability 服务、受管升级及其它协议域也未完成。
-完整 Desktop 验收和 Linux、macOS、Windows 发布打包仍待完成。
+编排、部分 capability 服务及其它协议域仍未完成;原生部署和更新能力不代表完整产品兼容。
 插件实现排在 Agent Graph 之后;OS 沙箱暂缓。Memory 留待单独重做,不移植旧实现,也不纳入本次重写。内容脱敏不实现。
diff --git a/package.json b/package.json
index be83413c74..11e1fbfb0e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
 {
   "name": "maka",
   "version": "0.2.0",
+  "nativeRuntimeHostVersion": "0.2.0-rust-preview.20260916.2",
   "license": "Apache-2.0",
   "private": true,
   "engines": {
diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs
index 73341b6432..8b8684b69b 100644
--- a/scripts/product-release.test.mjs
+++ b/scripts/product-release.test.mjs
@@ -26,7 +26,10 @@ import { isAbsolute, join } from 'node:path';
 import test from 'node:test';
 import { promisify } from 'node:util';
 import { parse as parseYaml } from 'yaml';
-import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs';
+import {
+  resolveDesktopBuilderConfig,
+  stageReleaseManifests,
+} from '../apps/desktop/electron-builder.config.mjs';
 import { writeDesktopReleaseInput } from './desktop-nightly-fixture.mjs';
 import { desktopReleaseTargets } from './desktop-release-targets.mjs';
 import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs';
@@ -354,19 +357,26 @@ test('Desktop packaging derives the Runtime Host setup package from product mani
   const checkedRootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
   assert.deepEqual(desktopBuilderConfig.extraMetadata, {
     runtimeHostSetupPackage: `maka-agent@${checkedRootManifest.version}`,
+    nativeRuntimeHostVersion: checkedRootManifest.nativeRuntimeHostVersion,
     makaUpdateChannel: 'release',
   });
+  assert.equal(
+    resolveDesktopBuilderConfig({ MAKA_NATIVE_CLI_VERSION: '0.2.0-rust-preview.42' }).extraMetadata
+      .nativeRuntimeHostVersion,
+    '0.2.0-rust-preview.42',
+  );
+  assert.throws(() => resolveDesktopBuilderConfig({ MAKA_NATIVE_CLI_VERSION: 'rust-preview' }));
   assert.deepEqual(desktopBuilderConfig.publish, [
     { provider: 'github', owner: 'apache', repo: 'maka' },
   ]);
 });
 
-test('Desktop stages release manifests before electron-builder builds the archive', async (t) => {
+test('Desktop staged release manifests omit development-only exports', async (t) => {
   const stage = await mkdtemp(join(tmpdir(), 'maka-desktop-release-manifests-'));
   t.after(() => rm(stage, { recursive: true, force: true }));
   const files = [{ filter: [...desktopBuilderConfig.files] }];
 
-  await desktopBuilderConfig.beforePack({
+  await stageReleaseManifests({
     packager: {
       config: { files },
       info: { tempDirManager: { createTempDir: async () => stage } },
diff --git a/scripts/rust/publish-cli.mjs b/scripts/rust/publish-cli.mjs
new file mode 100644
index 0000000000..3a1a24e6a2
--- /dev/null
+++ b/scripts/rust/publish-cli.mjs
@@ -0,0 +1,150 @@
+/*
+ * 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 { createHash } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
+import { createReadStream } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import { basename, join, resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { parseArgs } from 'node:util';
+import { compareProductReleaseVersions, parseProductReleaseVersion } from '../release-version.mjs';
+
+export const previewTargets = ['darwin-arm64', 'linux-x64-gnu', 'win32-x64'];
+
+/** Validate the complete set before the first external write; npm has no multi-package transaction. */
+export async function readPreviewRelease(directory) {
+  const packages = [];
+  for (const target of previewTargets) {
+    const receipt = JSON.parse(await readFile(join(directory, target + '.json'), 'utf8'));
+    const { name, version, integrity } = receipt;
+    if (
+      name !== '@maka-agent/cli-' + target ||
+      receipt.target !== target ||
+      !parseProductReleaseVersion(version).prerelease.join('.').startsWith('rust-preview.')
+    ) {
+      throw new Error('Invalid native preview receipt');
+    }
+    const filename = 'maka-agent-cli-' + target + '-' + version + '.tgz';
+    if (basename(receipt.archive) !== filename) throw new Error('Native archive name differs');
+    const archive = resolve(directory, filename);
+    const hash = createHash('sha512');
+    for await (const chunk of createReadStream(archive)) hash.update(chunk);
+    if (integrity !== 'sha512-' + hash.digest('base64'))
+      throw new Error('Native archive integrity differs');
+    const manifest = JSON.parse(
+      execFileSync('tar', ['-xOf', archive, 'package/package.json'], {
+        encoding: 'utf8',
+        maxBuffer: 64 * 1024,
+        timeout: 30_000,
+      }),
+    );
+    if (
+      manifest.name !== name ||
+      manifest.version !== version ||
+      !/^[a-f0-9]{128}$/.test(manifest.makaSource?.sha512 ?? '') ||
+      manifest.publishConfig?.tag !== 'rust-preview'
+    )
+      throw new Error('Native package provenance differs');
+    if (
+      packages.length &&
+      (version !== packages[0].version ||
+        JSON.stringify(manifest.makaSource) !== JSON.stringify(packages[0].source))
+    ) {
+      throw new Error('All platforms must come from the same source archive and build identifier');
+    }
+    packages.push({ name, version, archive, integrity, source: manifest.makaSource });
+  }
+  return packages;
+}
+
+async function metadata(path) {
+  const response = await fetch('https://registry.npmjs.org/' + path, {
+    redirect: 'error',
+    signal: AbortSignal.timeout(30_000),
+  });
+  if (response.status === 404) return null;
+  if (!response.ok) throw new Error('npm registry query failed: ' + response.status);
+  const text = await response.text();
+  if (text.length > 1024 * 1024) throw new Error('npm metadata exceeds limit');
+  return JSON.parse(text);
+}
+
+export async function publishPreviewRelease(directory, { provenance = false } = {}) {
+  const packages = await readPreviewRelease(directory);
+  // Preflight every platform before publishing any. Existing matching immutable
+  // versions are a successful retry, never grounds to upload different bytes.
+  for (const pkg of packages) {
+    const existing = await metadata(pkg.name + '/' + pkg.version);
+    if (existing && existing.dist?.integrity !== pkg.integrity)
+      throw new Error('Published version has different bytes: ' + pkg.name);
+    pkg.published = Boolean(existing);
+    const tags = await metadata('-/package/' + pkg.name.replace('/', '%2f') + '/dist-tags');
+    if (
+      tags?.['rust-preview'] &&
+      compareProductReleaseVersions(tags['rust-preview'], pkg.version) > 0
+    ) {
+      throw new Error('Refusing to move rust-preview backwards: ' + pkg.name);
+    }
+  }
+  for (const pkg of packages) {
+    if (!pkg.published) {
+      execFileSync(
+        'npm',
+        [
+          'publish',
+          pkg.archive,
+          '--tag',
+          'rust-preview',
+          '--access',
+          'public',
+          '--ignore-scripts',
+          ...(provenance ? ['--provenance'] : []),
+        ],
+        { stdio: 'inherit', timeout: 180_000 },
+      );
+    }
+    let verified = false;
+    for (let attempt = 0; attempt < 5; attempt++) {
+      const observed = await metadata(pkg.name + '/' + pkg.version);
+      if (observed?.dist?.integrity === pkg.integrity) {
+        verified = true;
+        break;
+      }
+      await new Promise((resolveDelay) => setTimeout(resolveDelay, 1000 * 2 ** attempt));
+    }
+    if (!verified)
+      throw new Error('Publication is unconfirmed; retry this same artifact set: ' + pkg.name);
+    console.log(pkg.name + '@' + pkg.version + ' verified');
+  }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+  const { values, positionals } = parseArgs({
+    allowPositionals: true,
+    options: {
+      publish: { type: 'boolean', default: false },
+      provenance: { type: 'boolean', default: false },
+    },
+  });
+  if (positionals.length !== 1)
+    throw new Error('Usage: publish-cli.mjs <artifact-directory> [--publish] [--provenance]');
+  if (values.publish) await publishPreviewRelease(positionals[0], values);
+  else console.log(JSON.stringify(await readPreviewRelease(positionals[0])));
+}
diff --git a/scripts/rust/release-cli.mjs b/scripts/rust/release-cli.mjs
index 9625747726..b042e77458 100644
--- a/scripts/rust/release-cli.mjs
+++ b/scripts/rust/release-cli.mjs
@@ -21,7 +21,7 @@ import { execFile, spawn } from 'node:child_process';
 import { constants } from 'node:fs';
 import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
-import { basename, join, resolve } from 'node:path';
+import { basename, dirname, join, resolve } from 'node:path';
 import { pathToFileURL } from 'node:url';
 import { parseArgs, promisify } from 'node:util';
 import { controlledProcessEnvironment, verifySourceCandidate } from '../asf-source-release.mjs';
@@ -57,11 +57,11 @@ export async function releaseNativeCli({
   buildId,
 }) {
   const platform = Object.hasOwn(nativeCliTargets, target) ? nativeCliTargets[target] : undefined;
-  if (!platform || !source || !notices || !validator || !output || !buildId) {
-    throw new Error(
-      'source, supported target, notices, validator, output, and build-id are required',
-    );
+  if (!platform || !source || !output || !buildId) {
+    throw new Error('source, supported target, output, and build-id are required');
   }
+  const native = platform.os === process.platform && platform.cpu === process.arch;
+  if (!native && !validator) throw new Error('Cross-target packaging requires a local validator');
   const stage = await mkdtemp(join(tmpdir(), 'maka-native-source-'));
   try {
     // Snapshot before validation so later changes to the input cannot change the build.
@@ -122,12 +122,27 @@ export async function releaseNativeCli({
       repositoryRoot,
       env,
     });
+    if (native) {
+      const executables = [
+        binary,
+        ...(platform.os === 'win32' ? [join(dirname(binary), 'maka-service.exe')] : []),
+      ];
+      for (const [index, executable] of executables.entries()) {
+        const { stdout } = await run(executable, ['--version'], {
+          timeout: 15_000,
+          windowsHide: true,
+        });
+        if (stdout.trim() !== 'maka ' + candidate.version)
+          throw new Error('Built CLI reports another source version');
+        await verifyCode(executable, join(stage, 'smoke-' + index + '.sqlite'));
+      }
+    }
     return await packNativeCli({
       target,
       version,
       binary,
-      notices,
-      validator,
+      notices: notices ?? join(repositoryRoot, 'crates/cli/DEPENDENCIES.rust.tsv'),
+      validator: validator ?? binary,
       output,
       repositoryRoot,
       source: { archive: basename(source), version: candidate.version, sha512: candidate.digest },
@@ -137,6 +152,20 @@ export async function releaseNativeCli({
   }
 }
 
+async function verifyCode(executable, log) {
+  const stdout = await new Promise((resolveOutput, reject) => {
+    const child = execFile(
+      executable,
+      ['code', '--log', log],
+      { encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024, windowsHide: true },
+      (error, stdout) => (error ? reject(error) : resolveOutput(stdout)),
+    );
+    child.stdin.end('if (6 * 7 !== 42) throw new Error("V8 smoke failed");');
+    child.stdin.on('error', reject);
+  });
+  if (JSON.parse(stdout).output?.ok !== true) throw new Error('Built CLI failed its real V8 smoke');
+}
+
 async function execute(command, args, options) {
   await new Promise((resolveCommand, reject) => {
     const child = spawn(command, args, { ...options, stdio: 'inherit', windowsHide: true });
diff --git a/scripts/rust/release-cli.test.mjs b/scripts/rust/release-cli.test.mjs
index 3b7bbbdf9c..870d154a47 100644
--- a/scripts/rust/release-cli.test.mjs
+++ b/scripts/rust/release-cli.test.mjs
@@ -25,6 +25,100 @@ import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import test from 'node:test';
 import { previewVersion, releaseNativeCli } from './release-cli.mjs';
+import { withSourceModule } from '../../tests/support/source.mjs';
+import { previewTargets, readPreviewRelease } from './publish-cli.mjs';
+
+test('publication rejects incomplete, mixed-source, or modified platform sets before writing npm', async () => {
+  const stage = await mkdtemp(join(tmpdir(), 'maka-publication-'));
+  try {
+    const version = '0.2.0-rust-preview.1';
+    const source = {
+      archive: 'apache-maka-0.2.0-incubating-src.tar.gz',
+      version: '0.2.0',
+      sha512: 'a'.repeat(128),
+    };
+    await mkdir(join(stage, 'package'));
+    async function pack(target, origin = source) {
+      const name = '@maka-agent/cli-' + target;
+      const archive = 'maka-agent-cli-' + target + '-' + version + '.tgz';
+      await writeFile(
+        join(stage, 'package/package.json'),
+        JSON.stringify({
+          name,
+          version,
+          makaSource: origin,
+          publishConfig: { tag: 'rust-preview' },
+        }),
+      );
+      execFileSync('tar', ['-czf', join(stage, archive), '-C', stage, 'package']);
+      const integrity =
+        'sha512-' +
+        createHash('sha512')
+          .update(await readFile(join(stage, archive)))
+          .digest('base64');
+      await writeFile(
+        join(stage, target + '.json'),
+        JSON.stringify({ name, version, target, archive, integrity }),
+      );
+    }
+    await pack(previewTargets[0]);
+    await assert.rejects(readPreviewRelease(stage), /ENOENT/);
+    for (const target of previewTargets.slice(1)) await pack(target);
+    assert.equal((await readPreviewRelease(stage)).length, 3);
+    await pack(previewTargets[2], { ...source, sha512: 'b'.repeat(128) });
+    await assert.rejects(readPreviewRelease(stage), /same source archive/);
+    await pack(previewTargets[2]);
+    await writeFile(
+      join(stage, 'maka-agent-cli-' + previewTargets[0] + '-' + version + '.tgz'),
+      'changed',
+    );
+    await assert.rejects(readPreviewRelease(stage), /integrity differs/);
+  } finally {
+    await rm(stage, { recursive: true, force: true });
+  }
+});
+
+test('Desktop uses its frozen native package version and isolates development overrides', async () => {
+  const stage = await mkdtemp(join(tmpdir(), 'maka-native-version-'));
+  try {
+    const appPath = join(stage, 'apps', 'desktop');
+    await mkdir(appPath, { recursive: true });
+    await writeFile(
+      join(stage, 'package.json'),
+      JSON.stringify({ nativeRuntimeHostVersion: '0.2.0-rust-preview.1' }),
+    );
+    await writeFile(
+      join(appPath, 'package.json'),
+      JSON.stringify({ version: '0.2.0', nativeRuntimeHostVersion: '0.2.0-rust-preview.2' }),
+    );
+    await withSourceModule(
+      'apps/desktop/src/main/native-runtime-host-setup.ts',
+      async ({ nativeRuntimeHostVersion }) => {
+        const input = { appPath, environment: {}, isPackaged: false };
+        assert.equal(await nativeRuntimeHostVersion(input), '0.2.0-rust-preview.1');
+        const environment = { MAKA_NATIVE_CLI_VERSION: '0.2.0-rust-preview.3' };
+        assert.equal(
+          await nativeRuntimeHostVersion({ ...input, environment }),
+          environment.MAKA_NATIVE_CLI_VERSION,
+        );
+        assert.equal(
+          await nativeRuntimeHostVersion({ ...input, environment, isPackaged: true }),
+          '0.2.0-rust-preview.2',
+        );
+        await assert.rejects(
+          nativeRuntimeHostVersion({
+            ...input,
+            environment: { MAKA_NATIVE_CLI_VERSION: 'rust-preview' },
+          }),
+        );
+        await writeFile(join(appPath, 'package.json'), JSON.stringify({ version: '0.2.0' }));
+        await assert.rejects(nativeRuntimeHostVersion({ ...input, isPackaged: true }));
+      },
+    );
+  } finally {
+    await rm(stage, { recursive: true, force: true });
+  }
+});
 
 test('preview builds have distinct package versions without changing the source version', () => {
   assert.equal(previewVersion('0.2.0', '20260916.1'), '0.2.0-rust-preview.20260916.1');


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


(maka) 02/02: ci: restrict native preview publication to the rewrite branch

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 e3176abde665bd18398b7b3243c34d2c96861c8b
Author: Wang <m4...@gmail.com>
AuthorDate: Wed Sep 16 18:34:16 2026 +0800

    ci: restrict native preview publication to the rewrite branch
    
    Generated-by: Codex
---
 .github/workflows/native-cli-preview.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/native-cli-preview.yml b/.github/workflows/native-cli-preview.yml
index 9da32e8d25..3ca5b590b3 100644
--- a/.github/workflows/native-cli-preview.yml
+++ b/.github/workflows/native-cli-preview.yml
@@ -131,8 +131,8 @@ jobs:
 
   publish:
     needs: build
-    if: inputs.publish
-    environment: npm-publication
+    if: inputs.publish && github.ref == 'refs/heads/feat/runtime-host-rust'
+    environment: rust-preview-publication
     runs-on: ubuntu-24.04
     timeout-minutes: 15
     permissions:


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