diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts
index 80028d25403..47f0bdc5128 100644
--- a/packages/cli/src/acp-integration/session/Session.test.ts
+++ b/packages/cli/src/acp-integration/session/Session.test.ts
@@ -19567,6 +19567,261 @@ describe('Session', () => {
expect(publishedActivities).toEqual(['idle', 'running']);
});
+ it('stamps a Goal turn’s tool results with the permit, and its own reads as bookkeeping', async () => {
+ // The evidence catalog derives provenance from this stamp: an
+ // unstamped tool result yields no catalog entry at all, so an ACP
+ // Goal could never cite an external_fact and could never prove a
+ // completion with anything but the model's own words.
+ const permit: core.GoalTurnPermit = {
+ goalId: 'goal-1',
+ revision: 1,
+ turnId: 'turn-stamp',
+ };
+ mockGoalRuntime.getSnapshot.mockReturnValue({
+ v: 2,
+ activity: 'running',
+ goal: {
+ goalId: 'goal-1',
+ revision: 1,
+ objective: 'check weather',
+ status: 'active',
+ evidenceCursor: { recordId: 'cursor-1' },
+ turnCount: 0,
+ activeTimeMs: 0,
+ tokensUsed: 0,
+ createdAt: 1234,
+ updatedAt: 1234,
+ },
+ });
+ mockGoalRuntime.permitForTurn.mockImplementation((turnKey: string) =>
+ turnKey === 'goal-runtime:turn-stamp' ? permit : undefined,
+ );
+ mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
+ mockToolRegistry.getTool.mockImplementation((name: string) => ({
+ name,
+ displayName: name,
+ kind: core.Kind.Read,
+ schema: { name, description: name, parameters: {} },
+ validateToolParams: vi.fn().mockReturnValue(null),
+ getDefaultPermission: vi.fn().mockResolvedValue('allow'),
+ getDescription: vi.fn().mockReturnValue(name),
+ toolLocations: vi.fn().mockReturnValue([]),
+ execute: vi.fn().mockResolvedValue(
+ name === 'run_shell_command'
+ ? {
+ llmContent: 'command failed',
+ returnDisplay: 'command failed',
+ error: {
+ message: 'command failed',
+ type: core.ToolErrorType.EXECUTION_FAILED,
+ },
+ }
+ : { llmContent: 'ok', returnDisplay: 'ok' },
+ ),
+ }));
+ mockChat.sendMessageStream = vi
+ .fn()
+ .mockResolvedValueOnce(
+ createStreamWithChunks([
+ {
+ type: core.StreamEventType.CHUNK,
+ value: {
+ functionCalls: [
+ { id: 'call-read', name: 'read_file', args: {} },
+ { id: 'call-goal', name: 'get_goal', args: {} },
+ { id: 'call-failed', name: 'run_shell_command', args: {} },
+ ],
+ },
+ },
+ ]),
+ )
+ .mockResolvedValue(createEmptyStream());
+
+ expect(boundGoalHost).toBeDefined();
+ await boundGoalHost!.startGoalTurn({
+ permit,
+ continuationContext: 'check weather',
+ });
+
+ await vi.waitFor(() => {
+ expect(mockGoalRuntime.finishTurn).toHaveBeenCalledWith(permit);
+ });
+
+ const optionsFor = (callId: string) =>
+ mockChatRecordingService.recordToolResult.mock.calls.find(
+ (call: unknown[]) =>
+ (call[1] as { callId?: string } | undefined)?.callId === callId,
+ )?.[2];
+ expect(optionsFor('call-read')).toEqual({ goalContext: permit });
+ expect(optionsFor('call-goal')).toEqual({
+ goalContext: permit,
+ provenance: 'goal_runtime',
+ });
+ // A failed command is evidence too -- it is exactly what an
+ // `infeasible` completion cites -- so the stamp must not depend on
+ // the tool succeeding.
+ expect(
+ mockChatRecordingService.recordToolResult.mock.calls.find(
+ (call: unknown[]) =>
+ (call[1] as { callId?: string } | undefined)?.callId ===
+ 'call-failed',
+ )?.[1],
+ ).toMatchObject({ status: 'error' });
+ expect(optionsFor('call-failed')).toEqual({ goalContext: permit });
+ });
+
+ it('does not stamp a background-notification turn with a permit inherited by lineage', async () => {
+ // A notification fires from async resources created inside the turn
+ // that spawned the task, so the Goal store is inherited into it long
+ // after that turn ended. The notification turn is not a Goal turn:
+ // its tool results must record unstamped, or they would become
+ // external_fact evidence for a turn that never made those calls.
+ const permit: core.GoalTurnPermit = {
+ goalId: 'goal-1',
+ revision: 1,
+ turnId: 'turn-stale',
+ };
+ mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
+ mockToolRegistry.getTool.mockImplementation((name: string) => ({
+ name,
+ displayName: name,
+ kind: core.Kind.Read,
+ schema: { name, description: name, parameters: {} },
+ validateToolParams: vi.fn().mockReturnValue(null),
+ getDefaultPermission: vi.fn().mockResolvedValue('allow'),
+ getDescription: vi.fn().mockReturnValue(name),
+ toolLocations: vi.fn().mockReturnValue([]),
+ execute: vi
+ .fn()
+ .mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
+ }));
+ mockChat.sendMessageStream = vi
+ .fn()
+ .mockResolvedValueOnce(
+ createStreamWithChunks([
+ {
+ type: core.StreamEventType.CHUNK,
+ value: {
+ functionCalls: [
+ { id: 'call-notified', name: 'read_file', args: {} },
+ ],
+ },
+ },
+ ]),
+ )
+ .mockResolvedValue(createEmptyStream());
+
+ // Enqueue from inside a Goal turn's store, as a task completion
+ // handler created during that turn would.
+ const notification = core.goalTurnContext.run(permit, () =>
+ session.enqueueBackgroundNotification({
+ displayText: 'Background agent completed.',
+ modelText: '',
+ taskId: 'agent-stale',
+ status: 'completed',
+ kind: 'agent',
+ }),
+ );
+ await expect(notification).resolves.toEqual({ accepted: true });
+ await vi.waitFor(() => {
+ expect(
+ mockChatRecordingService.recordToolResult.mock.calls.some(
+ (call: unknown[]) =>
+ (call[1] as { callId?: string } | undefined)?.callId ===
+ 'call-notified',
+ ),
+ ).toBe(true);
+ });
+
+ const call = mockChatRecordingService.recordToolResult.mock.calls.find(
+ (call: unknown[]) =>
+ (call[1] as { callId?: string } | undefined)?.callId ===
+ 'call-notified',
+ )!;
+ expect(call[2]).toBeUndefined();
+ });
+
+ it('does not stamp a cron turn with a permit inherited by lineage', async () => {
+ // `#drainCronQueue()` is fired from inside Goal-turn code paths (the
+ // turn settle's `finally`, among others), so a cron item can drain
+ // while a Goal permit is live in the store. A cron turn is never a
+ // Goal turn: its tool results must record unstamped, exactly like
+ // the notification turn above.
+ const permit: core.GoalTurnPermit = {
+ goalId: 'goal-1',
+ revision: 1,
+ turnId: 'turn-stale-cron',
+ };
+ mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
+ mockToolRegistry.getTool.mockImplementation((name: string) => ({
+ name,
+ displayName: name,
+ kind: core.Kind.Read,
+ schema: { name, description: name, parameters: {} },
+ validateToolParams: vi.fn().mockReturnValue(null),
+ getDefaultPermission: vi.fn().mockResolvedValue('allow'),
+ getDescription: vi.fn().mockReturnValue(name),
+ toolLocations: vi.fn().mockReturnValue([]),
+ execute: vi
+ .fn()
+ .mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }),
+ }));
+ let fireCron!: (job: { prompt: string; cronExpr: string }) => void;
+ const scheduler = {
+ hasPendingWork: true,
+ enableDurable: vi.fn().mockResolvedValue(undefined),
+ start: vi.fn(
+ (callback: (job: { prompt: string; cronExpr: string }) => void) => {
+ fireCron = callback;
+ },
+ ),
+ stop: vi.fn(),
+ list: vi.fn().mockReturnValue([]),
+ getExitSummary: vi.fn().mockReturnValue(undefined),
+ };
+ mockConfig.isCronEnabled = vi.fn().mockReturnValue(true);
+ mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler);
+ session.startCronScheduler();
+ await vi.waitFor(() => expect(scheduler.start).toHaveBeenCalled());
+ mockChat.sendMessageStream = vi
+ .fn()
+ .mockResolvedValueOnce(
+ createStreamWithChunks([
+ {
+ type: core.StreamEventType.CHUNK,
+ value: {
+ functionCalls: [
+ { id: 'call-cron', name: 'read_file', args: {} },
+ ],
+ },
+ },
+ ]),
+ )
+ .mockResolvedValue(createEmptyStream());
+
+ // Fire from inside a Goal turn's store, as a drain triggered from a
+ // Goal-turn code path would be.
+ core.goalTurnContext.run(permit, () =>
+ fireCron({ prompt: 'cron work', cronExpr: '* * * * *' }),
+ );
+ await vi.waitFor(() => {
+ expect(
+ mockChatRecordingService.recordToolResult.mock.calls.some(
+ (call: unknown[]) =>
+ (call[1] as { callId?: string } | undefined)?.callId ===
+ 'call-cron',
+ ),
+ ).toBe(true);
+ });
+
+ const call = mockChatRecordingService.recordToolResult.mock.calls.find(
+ (call: unknown[]) =>
+ (call[1] as { callId?: string } | undefined)?.callId ===
+ 'call-cron',
+ )!;
+ expect(call[2]).toBeUndefined();
+ });
+
it('runs a host-scheduled Goal turn with the canonical permit', async () => {
const permit: core.GoalTurnPermit = {
goalId: 'goal-1',
diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts
index 8db04674277..aa7f5042086 100644
--- a/packages/cli/src/acp-integration/session/Session.ts
+++ b/packages/cli/src/acp-integration/session/Session.ts
@@ -169,6 +169,7 @@ import {
refreshMemoryAfterManagedWrite,
refreshMemoryInstruction,
GoalPersistenceUnavailableError,
+ ambientGoalToolResultProvenance,
goalTurnContext,
sessionIdContext,
promptIdContext,
@@ -8092,10 +8093,14 @@ export class Session implements SessionContext {
* `_meta.source='cron'`, streams the model response, and handles tool calls.
*/
async #executeCronPrompt(item: CronQueueItem): Promise {
- // Same session-ID binding rationale as #executePrompt.
- return runWithInvocationContext(undefined, () =>
- sessionIdContext.run(this.config.getSessionId(), () =>
- this.#executeCronPromptInner(item),
+ // Same session-ID binding rationale as #executePrompt, and the same
+ // reason to leave the Goal store as the notification drain: a cron turn
+ // is never a Goal turn, whatever lineage it was scheduled from.
+ return goalTurnContext.exit(() =>
+ runWithInvocationContext(undefined, () =>
+ sessionIdContext.run(this.config.getSessionId(), () =>
+ this.#executeCronPromptInner(item),
+ ),
),
);
}
@@ -8872,9 +8877,17 @@ export class Session implements SessionContext {
this.currentShellNotificationActive = item.kind === 'shell';
this.#activeWorkChanged();
try {
- await runWithInvocationContext(undefined, () =>
- sessionIdContext.run(this.config.getSessionId(), () =>
- this.#executeBackgroundNotificationPromptInner(item),
+ // A notification fires from async resources created inside the
+ // turn that spawned the task, so a Goal permit can reach here by
+ // lineage after that turn is long over. This is not a Goal turn:
+ // leave the store, as #executePrompt does for every non-Goal turn,
+ // or the notification's tool results would be stamped as evidence
+ // for a turn that never made those calls.
+ await goalTurnContext.exit(() =>
+ runWithInvocationContext(undefined, () =>
+ sessionIdContext.run(this.config.getSessionId(), () =>
+ this.#executeBackgroundNotificationPromptInner(item),
+ ),
),
);
} finally {
@@ -9731,13 +9744,19 @@ export class Session implements SessionContext {
) {
return;
}
- this.config
- .getChatRecordingService()
- ?.recordToolResult(finalized[index].responseParts, {
+ const goalProvenance = ambientGoalToolResultProvenance(record.toolName);
+ this.config.getChatRecordingService()?.recordToolResult(
+ finalized[index].responseParts,
+ {
...record.metadata,
persistedOutputFiles: finalized[index].persistedOutputFiles,
artifacts: finalized[index].artifacts,
- });
+ },
+ // Passed only inside a Goal turn: outside one this call keeps its
+ // former two-argument shape, so nothing about ordinary recording
+ // changes.
+ ...(goalProvenance ? ([goalProvenance] as const) : ([] as const)),
+ );
});
return {
...result,
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
index 92e560e3c77..859c4d235a9 100644
--- a/packages/cli/src/nonInteractiveCli.test.ts
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -2841,6 +2841,154 @@ describe('runNonInteractive', () => {
).toEqual(['not_started', 'not_started', 'not_started']);
});
+ it('stamps a Goal turn’s tool results with the permit that asked for them', async () => {
+ // The evidence catalog derives provenance from this stamp; without it a
+ // headless Goal produces no `external_fact` at all, so a completion can
+ // only ever cite the model's own words and the verifier refuses it.
+ setupMetricsMock();
+ const recordToolResult = vi.fn();
+ (
+ mockConfig as Config & {
+ getChatRecordingService: () => {
+ recordToolResult: typeof recordToolResult;
+ finalize: ReturnType;
+ flush: ReturnType;
+ };
+ }
+ ).getChatRecordingService = () => ({
+ recordToolResult,
+ finalize: vi.fn(),
+ flush: vi.fn().mockResolvedValue(undefined),
+ });
+ vi.mocked(mockToolRegistry.getTool).mockReturnValue({
+ kind: Kind.Read,
+ } as unknown as ReturnType);
+ const permit = { goalId: 'g-1', revision: 2, turnId: 't-1' };
+ mockCoreExecuteToolCall.mockImplementation(
+ async (
+ _config: unknown,
+ request: { callId: string; name: string },
+ ) => ({
+ responseParts: [
+ {
+ functionResponse: {
+ id: request.callId,
+ name: request.name,
+ response:
+ request.callId === 'shell-failed'
+ ? { error: 'command failed' }
+ : { output: 'ok' },
+ },
+ },
+ ],
+ resultDisplay:
+ request.callId === 'shell-failed' ? 'command failed' : 'ok',
+ ...(request.callId === 'shell-failed'
+ ? {
+ error: new Error('command failed'),
+ errorType: ToolErrorType.EXECUTION_FAILED,
+ }
+ : {}),
+ }),
+ );
+ const goalToolCall = (callId: string, name: string) => ({
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ callId,
+ name,
+ args: {},
+ isClientInitiated: false,
+ prompt_id: 'p-goal',
+ goalContext: permit,
+ },
+ });
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(
+ createStreamFromEvents([
+ goalToolCall('shell-1', ToolNames.READ_FILE),
+ goalToolCall('goal-read', ToolNames.GET_GOAL),
+ goalToolCall('shell-failed', ToolNames.SHELL),
+ ] as unknown as ServerGeminiStreamEvent[]),
+ )
+ .mockReturnValueOnce(createStreamFromEvents(finishTurn));
+
+ await runNonInteractive(mockConfig, 'work the goal', 'p-goal');
+
+ const optionsByCallId = new Map(
+ recordToolResult.mock.calls.map((call) => [call[1]?.callId, call[2]]),
+ );
+ // An ordinary tool becomes citable external evidence...
+ expect(optionsByCallId.get('shell-1')).toEqual({ goalContext: permit });
+ // ...while the Goal's own reads stay out of the catalog.
+ expect(optionsByCallId.get('goal-read')).toEqual({
+ goalContext: permit,
+ provenance: 'goal_runtime',
+ });
+ // A failed command is evidence too -- it is exactly what an
+ // `infeasible` completion cites -- so the stamp must not depend on the
+ // tool succeeding.
+ expect(
+ recordToolResult.mock.calls.find(
+ (call) => call[1]?.callId === 'shell-failed',
+ )?.[1],
+ ).toMatchObject({ status: 'error' });
+ expect(optionsByCallId.get('shell-failed')).toEqual({
+ goalContext: permit,
+ });
+ });
+
+ it('leaves tool results outside a Goal turn unstamped', async () => {
+ setupMetricsMock();
+ const recordToolResult = vi.fn();
+ (
+ mockConfig as Config & {
+ getChatRecordingService: () => {
+ recordToolResult: typeof recordToolResult;
+ finalize: ReturnType;
+ flush: ReturnType;
+ };
+ }
+ ).getChatRecordingService = () => ({
+ recordToolResult,
+ finalize: vi.fn(),
+ flush: vi.fn().mockResolvedValue(undefined),
+ });
+ vi.mocked(mockToolRegistry.getTool).mockReturnValue({
+ kind: Kind.Read,
+ } as unknown as ReturnType);
+ mockCoreExecuteToolCall.mockImplementation(
+ async (
+ _config: unknown,
+ request: { callId: string; name: string },
+ ) => ({
+ responseParts: [
+ {
+ functionResponse: {
+ id: request.callId,
+ name: request.name,
+ response: { output: 'ok' },
+ },
+ },
+ ],
+ resultDisplay: 'ok',
+ }),
+ );
+ mockGeminiClient.sendMessageStream
+ .mockReturnValueOnce(
+ createStreamFromEvents(
+ toolCallEvents(['plain-1'], ToolNames.READ_FILE, 'p-plain'),
+ ),
+ )
+ .mockReturnValueOnce(createStreamFromEvents(finishTurn));
+
+ await runNonInteractive(mockConfig, 'plain work', 'p-plain');
+
+ expect(recordToolResult).toHaveBeenCalled();
+ for (const call of recordToolResult.mock.calls) {
+ expect(call[2]).toBeUndefined();
+ }
+ });
+
it('runs a batch of concurrency-safe tool calls concurrently', async () => {
setupMetricsMock();
// Kind.Read is concurrency-safe, so the whole batch is one parallel
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index d3d7d2f28b9..9d716f0e3a9 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -33,6 +33,7 @@ import {
InputFormat,
LoopType,
ToolNames,
+ goalToolResultProvenance,
uiTelemetryService,
parseAndFormatApiError,
createDebugLogger,
@@ -2245,18 +2246,23 @@ export async function runNonInteractive(
const { request, response } = orderedResponses[index];
const finalizedParts = finalized[index].responseParts;
toolResponseParts.push(...finalizedParts);
- chatRecordingService?.recordToolResult?.(finalizedParts, {
- callId: request.callId,
- status:
- statusByResponse.get(response) ??
- (response.error ? 'error' : 'success'),
- resultDisplay: response.resultDisplay,
- persistedOutputFiles: finalized[index].persistedOutputFiles,
- artifacts: finalized[index].artifacts,
- error: response.error,
- errorType: response.errorType,
- executionStatus: response.executionStatus,
- });
+ const goalProvenance = goalToolResultProvenance(request);
+ chatRecordingService?.recordToolResult?.(
+ finalizedParts,
+ {
+ callId: request.callId,
+ status:
+ statusByResponse.get(response) ??
+ (response.error ? 'error' : 'success'),
+ resultDisplay: response.resultDisplay,
+ persistedOutputFiles: finalized[index].persistedOutputFiles,
+ artifacts: finalized[index].artifacts,
+ error: response.error,
+ errorType: response.errorType,
+ executionStatus: response.executionStatus,
+ },
+ ...(goalProvenance ? ([goalProvenance] as const) : ([] as const)),
+ );
}
return {
diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts
index d229883b048..e7aeb7ce5e9 100644
--- a/packages/cli/src/ui/hooks/useGeminiStream.ts
+++ b/packages/cli/src/ui/hooks/useGeminiStream.ts
@@ -33,6 +33,7 @@ import {
SendMessageType,
createDebugLogger,
ToolNames,
+ goalToolResultProvenance,
getErrorMessage,
isNodeError,
MessageSenderType,
@@ -3919,7 +3920,6 @@ export const useGeminiStream = (
);
immediateDuplicateToolResponses.responses.forEach(
({ request, response }, index) => {
- const goalContext = request.goalContext;
config.getChatRecordingService?.()?.recordToolResult?.(
finalized[index].responseParts,
{
@@ -3932,15 +3932,7 @@ export const useGeminiStream = (
errorType: response.errorType,
executionStatus: response.executionStatus,
},
- goalContext
- ? request.name === ToolNames.GET_GOAL ||
- request.name === ToolNames.UPDATE_GOAL
- ? {
- goalContext: { ...goalContext },
- provenance: 'goal_runtime' as const,
- }
- : { goalContext: { ...goalContext } }
- : undefined,
+ goalToolResultProvenance(request),
);
},
);
@@ -4860,7 +4852,6 @@ export const useGeminiStream = (
(entry) => entry.responseParts,
);
orderedResponses.forEach(({ request, response, status }, index) => {
- const goalContext = request.goalContext;
config.getChatRecordingService?.()?.recordToolResult?.(
finalizedResponses[index].responseParts,
{
@@ -4874,15 +4865,7 @@ export const useGeminiStream = (
errorType: response.errorType,
executionStatus: response.executionStatus,
},
- goalContext
- ? request.name === ToolNames.GET_GOAL ||
- request.name === ToolNames.UPDATE_GOAL
- ? {
- goalContext: { ...goalContext },
- provenance: 'goal_runtime' as const,
- }
- : { goalContext: { ...goalContext } }
- : undefined,
+ goalToolResultProvenance(request),
);
});
diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts
index aae3567fd0e..db59b0cd3f9 100644
--- a/packages/core/src/core/coreToolScheduler.ts
+++ b/packages/core/src/core/coreToolScheduler.ts
@@ -205,6 +205,7 @@ import {
} from '../utils/invocation-context.js';
import { evaluateToolInvocationGuard } from './tool-invocation-guard.js';
import { goalTurnContext } from '../goals/goal-turn-context.js';
+import { goalToolResultProvenance } from '../goals/goal-tool-result-provenance.js';
const debugLogger = createDebugLogger('TOOL_SCHEDULER');
@@ -6284,28 +6285,14 @@ export class CoreToolScheduler {
error: call.response.error,
errorType: call.response.errorType,
};
- const goalContext = call.request.goalContext;
- if (!goalContext) {
- this.chatRecordingService.recordToolResult(
- call.response.responseParts,
- result,
- );
- } else if (
- call.request.name === ToolNames.GET_GOAL ||
- call.request.name === ToolNames.UPDATE_GOAL
- ) {
- this.chatRecordingService.recordToolResult(
- call.response.responseParts,
- result,
- { goalContext: { ...goalContext }, provenance: 'goal_runtime' },
- );
- } else {
- this.chatRecordingService.recordToolResult(
- call.response.responseParts,
- result,
- { goalContext: { ...goalContext } },
- );
- }
+ const goalProvenance = goalToolResultProvenance(call.request);
+ this.chatRecordingService.recordToolResult(
+ call.response.responseParts,
+ result,
+ // Passed only inside a Goal turn, so recording outside one keeps its
+ // two-argument shape.
+ ...(goalProvenance ? ([goalProvenance] as const) : ([] as const)),
+ );
}
}
diff --git a/packages/core/src/goals/goal-tool-result-provenance.test.ts b/packages/core/src/goals/goal-tool-result-provenance.test.ts
new file mode 100644
index 00000000000..ec9a28eced3
--- /dev/null
+++ b/packages/core/src/goals/goal-tool-result-provenance.test.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { ToolNames } from '../tools/tool-names.js';
+import { goalTurnContext } from './goal-turn-context.js';
+import {
+ ambientGoalToolResultProvenance,
+ goalToolResultProvenance,
+} from './goal-tool-result-provenance.js';
+
+const permit = { goalId: 'g-1', revision: 2, turnId: 't-1' };
+
+describe('goalToolResultProvenance', () => {
+ it('stamps an ordinary tool result with the permit that asked for it', () => {
+ // Without the stamp the catalog derives no provenance at all, so the
+ // result cannot become the `external_fact` a completion is proved with.
+ expect(
+ goalToolResultProvenance({
+ name: 'run_shell_command',
+ goalContext: permit,
+ }),
+ ).toEqual({ goalContext: permit });
+ });
+
+ it('copies the permit rather than aliasing the request', () => {
+ const request = { name: 'read_file', goalContext: { ...permit } };
+ const options = goalToolResultProvenance(request);
+ expect(options?.goalContext).not.toBe(request.goalContext);
+ expect(options?.goalContext).toEqual(permit);
+ });
+
+ it.each([ToolNames.GET_GOAL, ToolNames.UPDATE_GOAL])(
+ 'marks %s as the Goal’s own bookkeeping',
+ (name) => {
+ // Excluded from the catalog on purpose: a Goal that cited its own reads
+ // as proof would be arguing in a circle.
+ expect(goalToolResultProvenance({ name, goalContext: permit })).toEqual({
+ goalContext: permit,
+ provenance: 'goal_runtime',
+ });
+ },
+ );
+
+ it('leaves a tool call made outside a Goal turn unstamped', () => {
+ expect(goalToolResultProvenance({ name: 'read_file' })).toBeUndefined();
+ });
+});
+
+describe('ambientGoalToolResultProvenance', () => {
+ it('reads the permit from the surrounding Goal turn', () => {
+ const options = goalTurnContext.run(permit, () =>
+ ambientGoalToolResultProvenance('run_shell_command'),
+ );
+ expect(options).toEqual({ goalContext: permit });
+ });
+
+ it('applies the same bookkeeping rule inside a turn', () => {
+ const options = goalTurnContext.run(permit, () =>
+ ambientGoalToolResultProvenance(ToolNames.GET_GOAL),
+ );
+ expect(options).toEqual({
+ goalContext: permit,
+ provenance: 'goal_runtime',
+ });
+ });
+
+ it('stamps nothing outside a Goal turn', () => {
+ expect(ambientGoalToolResultProvenance('read_file')).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/goals/goal-tool-result-provenance.ts b/packages/core/src/goals/goal-tool-result-provenance.ts
new file mode 100644
index 00000000000..2d69c2dfa5a
--- /dev/null
+++ b/packages/core/src/goals/goal-tool-result-provenance.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { RecordToolResultOptions } from '../services/chatRecordingService.js';
+import { ToolNames } from '../tools/tool-names.js';
+import type { GoalTurnPermit } from './goal-protocol.js';
+import { goalTurnContext } from './goal-turn-context.js';
+
+/** The slice of a tool-call request this reads. */
+export interface GoalToolResultRequest {
+ name: string;
+ goalContext?: GoalTurnPermit;
+}
+
+/**
+ * How a finished tool call should be recorded, so its result can become Goal
+ * evidence.
+ *
+ * The evidence catalog derives a record's provenance from the Goal permit
+ * stamped on it (`goal-evidence.ts`: no parsed goal context, no provenance,
+ * no catalog entry). A tool result recorded without the stamp is therefore
+ * invisible to the catalog -- and since `tool_result` is the only provenance
+ * that maps to `external_fact`, an unstamped host produces a Goal that can
+ * never prove anything about the world: completions citing only the model's
+ * own words are refused by the verifier, and the `infeasible` and `external`
+ * blockers, which require external evidence, are unreachable.
+ *
+ * `get_goal` and `update_goal` are stamped as `goal_runtime` instead: they are
+ * the Goal's own bookkeeping, and a catalog that cited its own reads as proof
+ * would be circular.
+ *
+ * Every site that records tool results during a Goal turn routes through
+ * here -- the interactive scheduler, both TUI recording paths, headless, and
+ * ACP -- so the rule cannot drift between them.
+ */
+export function goalToolResultProvenance(
+ request: GoalToolResultRequest,
+): RecordToolResultOptions | undefined {
+ const { goalContext } = request;
+ if (!goalContext) return undefined;
+ if (
+ request.name === ToolNames.GET_GOAL ||
+ request.name === ToolNames.UPDATE_GOAL
+ ) {
+ return { goalContext: { ...goalContext }, provenance: 'goal_runtime' };
+ }
+ return { goalContext: { ...goalContext } };
+}
+
+/**
+ * The same rule for a host that executes tools straight from the model's
+ * function calls and so has no `ToolCallRequestInfo` to read a permit from.
+ *
+ * The permit comes from the ambient Goal turn context, which the host enters
+ * around the whole turn -- including the tool calls it issues -- so a result
+ * recorded here belongs to the turn that asked for it.
+ */
+export function ambientGoalToolResultProvenance(
+ toolName: string,
+): RecordToolResultOptions | undefined {
+ const goalContext = goalTurnContext.getStore();
+ return goalToolResultProvenance({
+ name: toolName,
+ ...(goalContext ? { goalContext } : {}),
+ });
+}
diff --git a/packages/core/src/goals/index.ts b/packages/core/src/goals/index.ts
index a64afd63af6..7395fde648e 100644
--- a/packages/core/src/goals/index.ts
+++ b/packages/core/src/goals/index.ts
@@ -64,6 +64,7 @@ export type {
LegacyGoalTerminal,
} from './goal-legacy-projection.js';
export * from './goal-evidence.js';
+export * from './goal-tool-result-provenance.js';
export * from './goal-checkpoint.js';
export * from './goal-checkpoint-verifier.js';
export * from './goal-verifier.js';