You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/05 07:58:55 UTC

(maka) 01/06: fix(artifacts): protect workflow-owned evidence from user deletion

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

Astro-Han pushed a commit to branch refactor/4788-disposable-artifacts
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 0b3ba791009c4640ca3438393a250a1c3500dfe7
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Sep 5 15:42:14 2026 +0800

    fix(artifacts): protect workflow-owned evidence from user deletion
---
 .../workbar/tools/artifacts/artifact-pane.tsx       |  8 ++++----
 packages/core/src/__tests__/artifacts.test.ts       | 11 +++++++++++
 packages/core/src/artifacts.ts                      | 20 +++++++++++++-------
 .../src/__tests__/artifact-protocol.test.ts         |  5 ++---
 .../src/__tests__/artifact-two-client-uds.test.ts   | 21 +++++++++++++++------
 packages/runtime-host/src/protocol/artifact.ts      |  2 +-
 packages/runtime-host/src/protocol/index.ts         |  2 +-
 .../runtime-host/src/server/artifact-coordinator.ts |  9 +++++++++
 .../storage/src/__tests__/artifact-store.test.ts    | 13 +++++++++----
 packages/storage/src/artifact-store.ts              |  3 +++
 10 files changed, 68 insertions(+), 26 deletions(-)

diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
index 08db683a1d..aca5c1eaa2 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx
@@ -60,7 +60,7 @@ import {
   Copy,
   Trash2,
 } from '@maka/ui/icons';
-import type { ArtifactDescriptor, ArtifactKind } from '@maka/core/artifacts';
+import { canUserDeleteArtifact, type ArtifactDescriptor, type ArtifactKind } from '@maka/core/artifacts';
 import type { UiLocale } from '@maka/core/ui-locale';
 import { formatRelativeTimestamp } from '@maka/core/relative-time';
 import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction';
@@ -353,6 +353,7 @@ export function ArtifactPane(props: {
   async function deleteArtifact(artifactId: string) {
     const actionSessionId = sessionId;
     const record = activeRecords.find((entry) => entry.id === artifactId);
+    if (!record || !canUserDeleteArtifact(record)) return;
     const name = record?.name ?? copy.pane.fallbackName;
     const ok = await toast.confirm({
       title: copy.pane.deleteTitle(name),
@@ -559,15 +560,14 @@ export function ArtifactPane(props: {
                       onClick: () => void runArtifactAction(`${previewRecord.id}:copy`, () => copyText(previewRecord.id)),
                     }]
                   : []),
-                { type: 'divider' as const },
-                {
+                ...(canUserDeleteArtifact(previewRecord) ? [{ type: 'divider' as const }, {
                   label: copy.pane.delete,
                   icon: <Trash2 size={ICON_SIZE.control} aria-hidden="true" />,
                   onClick: () => void runArtifactAction(
                     `${previewRecord.id}:delete`,
                     () => deleteArtifact(previewRecord.id),
                   ),
-                },
+                }] : []),
               ]}
             />
           </header>
diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts
index 370ee88d1c..93557757ce 100644
--- a/packages/core/src/__tests__/artifacts.test.ts
+++ b/packages/core/src/__tests__/artifacts.test.ts
@@ -22,6 +22,8 @@ import { describe, test } from 'node:test';
 import {
   ARTIFACT_ENTITY_ID_MAX_CHARS,
   ARTIFACT_TURN_KEY_MAX_CHARS,
+  ARTIFACT_SOURCES,
+  canUserDeleteArtifact,
   isArtifactChildResultOutput,
   isArtifactSharedSessionReadable,
   isArtifactUserVisible,
@@ -57,6 +59,15 @@ describe('Artifact turn key', () => {
 });
 
 describe('Artifact source policy', () => {
+  test('keeps workflow-owned evidence out of independent user deletion', () => {
+    for (const source of ARTIFACT_SOURCES) {
+      assert.equal(
+        canUserDeleteArtifact({ source }),
+        source === 'tool_result' || source === 'user_upload',
+        source,
+      );
+    }
+  });
   test('includes produced outputs in child results without leaking internal artifacts', () => {
     assert.equal(isArtifactChildResultOutput({ source: 'tool_result' }), true);
     assert.equal(isArtifactChildResultOutput({ source: 'subagent_writeback' }), true);
diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts
index e904740bc8..48c058ca76 100644
--- a/packages/core/src/artifacts.ts
+++ b/packages/core/src/artifacts.ts
@@ -142,18 +142,19 @@ export interface ArtifactRecord extends ArtifactDescriptor {
 }
 
 interface ArtifactSourcePolicy {
+  readonly userDeletable: boolean;
   readonly userVisible: boolean;
   readonly sharedReadable: boolean;
 }
 
 const ARTIFACT_SOURCE_POLICIES = {
-  tool_result: { userVisible: false, sharedReadable: true },
-  tool_result_projection: { userVisible: false, sharedReadable: true },
-  tool_result_archive: { userVisible: false, sharedReadable: false },
-  subagent_writeback: { userVisible: true, sharedReadable: false },
-  deep_research: { userVisible: true, sharedReadable: false },
-  user_upload: { userVisible: false, sharedReadable: true },
-  session_effect: { userVisible: false, sharedReadable: false },
+  tool_result: { userDeletable: true, userVisible: false, sharedReadable: true },
+  tool_result_projection: { userDeletable: false, userVisible: false, sharedReadable: true },
+  tool_result_archive: { userDeletable: false, userVisible: false, sharedReadable: false },
+  subagent_writeback: { userDeletable: false, userVisible: true, sharedReadable: false },
+  deep_research: { userDeletable: false, userVisible: true, sharedReadable: false },
+  user_upload: { userDeletable: true, userVisible: false, sharedReadable: true },
+  session_effect: { userDeletable: false, userVisible: false, sharedReadable: false },
 } as const satisfies Record<ArtifactSource, ArtifactSourcePolicy>;
 
 const CHILD_RESULT_OUTPUT_SOURCES = new Set<ArtifactSource>([
@@ -167,6 +168,11 @@ export function isArtifactUserVisible(record: Pick<ArtifactRecord, 'source'>): b
   return ARTIFACT_SOURCE_POLICIES[record.source].userVisible;
 }
 
+/** Visibility does not grant permission to destroy evidence owned by a runtime workflow. */
+export function canUserDeleteArtifact(record: Pick<ArtifactRecord, 'source'>): boolean {
+  return ARTIFACT_SOURCE_POLICIES[record.source].userDeletable;
+}
+
 export function isArtifactSharedSessionReadable(record: Pick<ArtifactRecord, 'source'>): boolean {
   return ARTIFACT_SOURCE_POLICIES[record.source].sharedReadable;
 }
diff --git a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
index f54a7fa659..906289e27f 100644
--- a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
@@ -255,9 +255,8 @@ describe('Artifact protocol', () => {
       () => failure('artifact.query', 'operation_conflict', 'Protected runtime evidence'),
       isInvalidFrame,
     );
-    assert.throws(
-      () => failure('artifact.delete', 'operation_conflict', 'Protected runtime evidence'),
-      isInvalidFrame,
+    assert.doesNotThrow(() =>
+      failure('artifact.delete', 'operation_conflict', 'Protected runtime evidence'),
     );
     assert.throws(() => failure('artifact.delete', 'outcome_unknown', 'Unknown'), isInvalidFrame);
   });
diff --git a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts
index 68fe3b7635..06b45444f4 100644
--- a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts
@@ -182,11 +182,10 @@ test('production Host ignores Artifact publication residue and preserves deletes
 
       for (const [index, artifact] of PROTECTED_ARTIFACTS.entries()) {
         const client = index % 2 === 0 ? desktop : tui;
-        const deleted = await client.request('artifact.delete', {
-          sessionId,
-          artifactId: artifact.id,
-        });
-        assert.deepEqual(deleted, { kind: 'deleted' });
+        await assert.rejects(
+          client.request('artifact.delete', { sessionId, artifactId: artifact.id }),
+          operationError('operation_conflict'),
+        );
       }
 
       const stale = await tui.request('artifact.query', {
@@ -218,7 +217,17 @@ test('production Host ignores Artifact publication residue and preserves deletes
     successor = await startHost(root, capability.rootId);
     const observer = await connectClient(root);
     try {
-      for (const artifactId of [...PROTECTED_ARTIFACTS.map(({ id }) => id), deleteA, deleteB]) {
+      for (const { id: artifactId } of PROTECTED_ARTIFACTS) {
+        assert.ok((await getArtifact(observer, sessionId, artifactId)).artifact);
+        const read = await observer.request('artifact.query', {
+          kind: 'read_text',
+          sessionId,
+          artifactId,
+        });
+        assert.equal(read.kind, 'text');
+        assert.ok(read.kind === 'text' && read.preview.ok);
+      }
+      for (const artifactId of [deleteA, deleteB]) {
         const getResult = await getArtifact(observer, sessionId, artifactId);
         const readResult = await observer.request('artifact.query', {
           kind: 'read_text',
diff --git a/packages/runtime-host/src/protocol/artifact.ts b/packages/runtime-host/src/protocol/artifact.ts
index c2bf3f7449..0c78f530ee 100644
--- a/packages/runtime-host/src/protocol/artifact.ts
+++ b/packages/runtime-host/src/protocol/artifact.ts
@@ -54,7 +54,7 @@ const QUERY_ERRORS = [
   'not_found',
   'persistence_failed',
 ] as const;
-const DELETE_ERRORS = QUERY_ERRORS;
+const DELETE_ERRORS = [...QUERY_ERRORS, 'operation_conflict'] as const;
 const ARTIFACT_REQUIRED_FIELDS = [
   'id',
   'sessionId',
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index e6fd4a34fd..f93a4f1666 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -101,7 +101,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 114 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 115 as const;
 // 113: Artifacts are physically deleted and no longer expose tombstone status.
 // 112: Owners can query the Host execution environment through an extensible,
 // bounded resource-envelope contract. Older Hosts do not implement the query.
diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts
index 569346406a..9b507ba550 100644
--- a/packages/runtime-host/src/server/artifact-coordinator.ts
+++ b/packages/runtime-host/src/server/artifact-coordinator.ts
@@ -479,6 +479,15 @@ export class HostArtifactCoordinator {
           error: { code: 'not_found', message: 'Artifact was not found' },
         };
       }
+      if (deleted.kind === 'protected') {
+        return {
+          ok: false,
+          error: {
+            code: 'operation_conflict',
+            message: 'Runtime-owned evidence cannot be deleted independently of its workflow',
+          },
+        };
+      }
       return {
         ok: true,
         result: encodeArtifactDeleteResult({ kind: 'deleted' }),
diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts
index 0bfef725c0..e290c01c34 100644
--- a/packages/storage/src/__tests__/artifact-store.test.ts
+++ b/packages/storage/src/__tests__/artifact-store.test.ts
@@ -460,7 +460,7 @@ describe('SQLite Artifact store', () => {
     });
   });
 
-  test('user delete physically removes every artifact source within its Session', async () => {
+  test('user deletion respects the current artifact source while owner cleanup remains possible', async () => {
     await withWorkspace(async (root) => {
       const authority = createArtifactStoreWriteAuthority(root);
       const { store } = authority;
@@ -475,13 +475,18 @@ describe('SQLite Artifact store', () => {
 
       assert.equal(
         (await store.deleteUserArtifactInSession(input.sessionId, input.id)).kind,
-        'deleted',
+        'protected',
       );
-      assert.equal(await getArtifact(store, input.id), null);
+      assert.equal((await getArtifact(store, input.id))?.source, 'deep_research');
+      assert.deepEqual(await store.readTextInSession(input.sessionId, input.id), {
+        ok: true,
+        text: 'protected replacement',
+      });
       assert.deepEqual(await store.deleteUserArtifactInSession('different-session', input.id), {
         kind: 'not_found',
       });
 
+      await store.purgeSessionArtifacts(input.sessionId);
       assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), {
         kind: 'not_found',
       });
@@ -652,7 +657,7 @@ describe('SQLite Artifact store', () => {
       const input = deepResearchArtifactInput('stable-revive', '# Revivable');
       const store = createArtifactStore(root);
       const first = await store.create(input);
-      await store.deleteUserArtifactInSession(input.sessionId, first.id);
+      await store.deleteOwnedArtifactInSession(input.sessionId, first.id, input.source);
       assert.equal(await getArtifact(store, first.id), null);
 
       const recreated = await createArtifactStore(root).create(input);
diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts
index 7da9add065..c297bb62a6 100644
--- a/packages/storage/src/artifact-store.ts
+++ b/packages/storage/src/artifact-store.ts
@@ -42,6 +42,7 @@ import {
   ArtifactRecord,
   ArtifactSource,
   ArtifactTextReadResult,
+  canUserDeleteArtifact,
   isArtifactTurnKey,
   isCanonicalArtifactEntityId,
 } from '@maka/core/artifacts';
@@ -171,6 +172,7 @@ export interface DurableArtifactAttachmentReader {
 
 export type ArtifactUserDeleteResult =
   | { readonly kind: 'deleted' }
+  | { readonly kind: 'protected' }
   | { readonly kind: 'not_found' };
 
 export interface ArtifactAuthorityStore extends DurableArtifactAttachmentReader {
@@ -684,6 +686,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
       const snapshot = this.sessionSnapshot(sessionId);
       const existing = snapshot.records.find((record) => record.id === artifactId);
       if (!existing) return { kind: 'not_found' };
+      if (!canUserDeleteArtifact(existing)) return { kind: 'protected' };
       await this.purgeRecordsUnlocked([existing]);
       return { kind: 'deleted' };
     });


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