1
0
Эх сурвалжийг харах

feat(web): re-land the goals wire domain as mutation-only ref acknowledgements

Six mutation RPCs (create/edit/pause/resume/complete/clear) move into
dsh-host-apiproxy (the PR's host/runtime carrier is gone): goalService()
resolves ctx.get('goals') with a loud absence error, mutateGoal() resolves
the session's agent (agentFor, the command.* implicit-resume precedent) and
acknowledges with the new CAS ref only. GoalError codes ride err.details.

goal.get and the wire GoalView/goalViewSchema are gone: the read side is the
'goal' session projection (whole values on the history tail page and
session/projection frames), so responses never feed client state — the rule
whose absence forced the original PR's write-revision fences.
imccyu 1 сар өмнө
parent
commit
1c2923c84f

+ 5 - 5
packages/client/connection/tests/fake-api.ts

@@ -128,11 +128,11 @@ export class FakeApiClient implements IApiClient {
   }
 
   readonly goals: IApiClient['goals'] = {
-    create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
+    create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
     clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
   }
 

+ 7 - 7
packages/client/connection/tests/fixture.spec.ts

@@ -698,6 +698,13 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
     const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
     if (!moved.result.ok) throw new Error('workspace move failed')
     expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
+    const ref = { id: 'fx-goal-1' as never, revision: 1 }
+    expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false)
+    expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false)
+    expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false)
+    expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false)
+    expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
+    expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false)
   })
 
   it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {
@@ -745,13 +752,6 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
       workspaceId: 'fx-ws-fixture' as WorkspaceId,
       sessionId: sid('fx-query-dropped'),
     })).rejects.toThrow(/dropped session\.create response/)
-    const ref = { id: 'fx-goal-1' as never, revision: 1 }
-    expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false)
-    expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false)
-    expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false)
-    expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false)
-    expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false)
-    expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false)
   })
 
   it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {

+ 5 - 5
packages/client/runtime/tests/fake-api.ts

@@ -154,11 +154,11 @@ export class FakeApiClient implements IApiClient {
   }
 
   readonly goals: IApiClient['goals'] = {
-    create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
-    complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
+    create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
+    complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
     clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
   }
 

+ 1 - 0
packages/host/apiproxy/package.json

@@ -43,6 +43,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
+    "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",

+ 84 - 1
packages/host/apiproxy/src/api-proxy.ts

@@ -24,12 +24,15 @@ import {
 // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
 import type {} from '@deepseek-ai/dsh-tools'
 import type {
-  ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
+  ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
   MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
   WorkspaceId, WorkspaceView,
 } from './api/index.ts'
 // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
 import type {} from '@deepseek-ai/dsh-session-projection'
+// GoalError narrows domain rejections to their stable codes at the wire boundary.
+import { GoalError } from '@deepseek-ai/dsh-goal'
+import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
 // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
 import type {} from '@deepseek-ai/dsh-commands'
 import type {} from '@deepseek-ai/dsh-skill'
@@ -645,6 +648,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     return operation
   }
 
+  /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
+  function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
+    const goals = ctx.get('goals')
+    if (goals === undefined) {
+      return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
+    }
+    return goals
+  }
+
+  /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
+  function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
+    const details = error instanceof GoalError ? { goalCode: error.code } : {}
+    return err(request, { code: 'internal', message: String(error), details })
+  }
+
+  /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
+  async function mutateGoal(
+    request: RpcRequest<{ sessionId: SessionId }>,
+    mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
+  ): Promise<RpcResponse<{ ref: GoalRef }>> {
+    const goals = goalService()
+    if ('error' in goals) return err(request, goals.error)
+    const found = await agentFor(request.payload.sessionId)
+    if ('error' in found) return err(request, found.error)
+    try {
+      const ref = mutation(goals, found.agent)
+      return ok(request, { ref: { id: ref.id, revision: ref.revision } })
+    } catch (error: unknown) {
+      return goalError(request, error)
+    }
+  }
+
   return {
     sessions: {
       // Attached sessions summarize from memory; persisted-but-unattached (cold)
@@ -1090,6 +1125,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       },
     },
 
+    goals: {
+      // Mutations only — the read side is the 'goal' session projection.
+      // Every verb resolves the session's agent (agentFor: implicit cold
+      // resume, the command.* precedent) and acknowledges with the new CAS
+      // ref; the committed goal/change event carries the whole value to every
+      // client through the projection frames.
+      async create(request) {
+        const { objective, maxGoalRounds } = request.payload
+        return mutateGoal(request, (goals, agent) => goals.create(agent, {
+          objective,
+          ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
+        }))
+      },
+
+      async edit(request) {
+        const { ref, objective, maxGoalRounds } = request.payload
+        return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
+          ...(objective !== undefined ? { objective } : {}),
+          ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
+        }))
+      },
+
+      async pause(request) {
+        return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
+      },
+
+      async resume(request) {
+        return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
+      },
+
+      async complete(request) {
+        return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
+      },
+
+      async clear(request) {
+        const goals = goalService()
+        if ('error' in goals) return err(request, goals.error)
+        const found = await agentFor(request.payload.sessionId)
+        if ('error' in found) return err(request, found.error)
+        try {
+          goals.clear(found.agent, request.payload.ref)
+          return ok(request, { cleared: true as const })
+        } catch (error: unknown) {
+          return goalError(request, error)
+        }
+      },
+    },
+
     skills: {
       // Skill lookup never touches the Agent registry: the session address
       // resolves to a canonical cwd from the host-resident session header, so

+ 11 - 36
packages/host/apiproxy/src/api/goals.schema.ts

@@ -1,10 +1,12 @@
 /**
- * goals domain zod schemas.
+ * goals domain zod schemas. Mutation-only shapes: every value schema is a
+ * `{ ref }` acknowledgement (clear: `{ cleared }`) — the current goal state
+ * travels exclusively on the 'goal' session projection.
  */
 
 import { z } from 'zod'
 import type { Wire } from './rpc.schema.ts'
-import type { GoalRef, GoalView, RequestPayload, ResponseValue } from './index.ts'
+import type { GoalRef, RequestPayload, ResponseValue } from './index.ts'
 
 /** GoalRef schema. */
 export const goalRefSchema = z.object({
@@ -12,25 +14,8 @@ export const goalRefSchema = z.object({
   revision: z.number().int().positive(),
 }) as unknown as z.ZodType<Wire<GoalRef>>
 
-/** Goal block reason schema. */
-export const goalBlockReasonSchema = z.object({
-  code: z.string(),
-  message: z.string(),
-})
-
-/** GoalView schema. */
-export const goalViewSchema = z.object({
-  id: z.string(),
-  revision: z.number().int().positive(),
-  objective: z.string(),
-  phase: z.union([z.literal('active'), z.literal('paused'), z.literal('blocked'), z.literal('complete')]),
-  blockedReason: goalBlockReasonSchema.optional(),
-  maxGoalRounds: z.number().int().positive(),
-  roundsStarted: z.number().int().nonnegative(),
-  createdAt: z.number(),
-  updatedAt: z.number(),
-  activation: z.union([z.literal('armed'), z.literal('disarmed')]),
-}) as unknown as z.ZodType<Wire<GoalView>>
+/** Shared `{ ref }` acknowledgement value of every non-clear mutation. */
+const goalRefValueSchema = z.object({ ref: goalRefSchema })
 
 /** goal.create request payload. */
 export const goalCreateRequestSchema = z.object({
@@ -40,9 +25,7 @@ export const goalCreateRequestSchema = z.object({
 }) as unknown as z.ZodType<Wire<RequestPayload<'goal.create'>>>
 
 /** goal.create response value. */
-export const goalCreateValueSchema = z.object({
-  goal: goalViewSchema,
-}) as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
+export const goalCreateValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.create'>>>
 
 /** goal.edit request payload. */
 export const goalEditRequestSchema = z.object({
@@ -55,9 +38,7 @@ export const goalEditRequestSchema = z.object({
 }) as unknown as z.ZodType<Wire<RequestPayload<'goal.edit'>>>
 
 /** goal.edit response value. */
-export const goalEditValueSchema = z.object({
-  goal: goalViewSchema,
-}) as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
+export const goalEditValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.edit'>>>
 
 /** goal.pause request payload. */
 export const goalPauseRequestSchema = z.object({
@@ -66,9 +47,7 @@ export const goalPauseRequestSchema = z.object({
 }) as unknown as z.ZodType<Wire<RequestPayload<'goal.pause'>>>
 
 /** goal.pause response value. */
-export const goalPauseValueSchema = z.object({
-  goal: goalViewSchema,
-}) as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
+export const goalPauseValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.pause'>>>
 
 /** goal.resume request payload. */
 export const goalResumeRequestSchema = z.object({
@@ -77,9 +56,7 @@ export const goalResumeRequestSchema = z.object({
 }) as unknown as z.ZodType<Wire<RequestPayload<'goal.resume'>>>
 
 /** goal.resume response value. */
-export const goalResumeValueSchema = z.object({
-  goal: goalViewSchema,
-}) as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
+export const goalResumeValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.resume'>>>
 
 /** goal.complete request payload. */
 export const goalCompleteRequestSchema = z.object({
@@ -88,9 +65,7 @@ export const goalCompleteRequestSchema = z.object({
 }) as unknown as z.ZodType<Wire<RequestPayload<'goal.complete'>>>
 
 /** goal.complete response value. */
-export const goalCompleteValueSchema = z.object({
-  goal: goalViewSchema,
-}) as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
+export const goalCompleteValueSchema = goalRefValueSchema as unknown as z.ZodType<Wire<ResponseValue<'goal.complete'>>>
 
 /** goal.clear request payload. */
 export const goalClearRequestSchema = z.object({

+ 12 - 48
packages/host/apiproxy/src/api/goals.ts

@@ -1,6 +1,12 @@
 /**
  * goals domain contract. Method signatures are the source of truth:
  * unary methods take the RpcRequest<P> narrow form and the impl echoes rpcId.
+ *
+ * Mutations only: the read side is the 'goal' session projection (history
+ * tail-page projections block + session/projection frames), so there is no
+ * goal.get and no wire goal view — responses acknowledge with the new CAS
+ * ref and never feed client state (the committed goal/change event reaches
+ * every client through the mux stream carrying the same whole value).
  */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
@@ -16,69 +22,27 @@ export interface GoalRef {
   readonly revision: number
 }
 
-/** Durable continuation phase. */
-export type GoalPhase =
-  | 'active'
-  | 'paused'
-  | 'blocked'
-  | 'complete'
-
-/** Machine-routable and human-readable explanation for a blocked goal. */
-export interface GoalBlockReason {
-  readonly code: string
-  readonly message: string
-}
-
-/** Whether this live process may automatically continue an active goal. */
-export type GoalActivation = 'armed' | 'disarmed'
-
-/** Current goal projection, including values derived from the session log. */
-export interface GoalView {
-  readonly id: GoalId
-  readonly revision: number
-  readonly objective: string
-  readonly phase: GoalPhase
-  readonly blockedReason?: GoalBlockReason
-  readonly maxGoalRounds: number
-  readonly roundsStarted: number
-  readonly createdAt: number
-  readonly updatedAt: number
-  readonly activation: GoalActivation
-}
-
-/** Input whose omitted round cap is resolved by the service configuration. */
-export interface CreateGoalRequest {
-  readonly objective: string
-  readonly maxGoalRounds?: number
-}
-
-/** Fields changed by an edit; at least one must be present. */
-export interface EditGoalRequest {
-  readonly objective?: string
-  readonly maxGoalRounds?: number
-}
-
-/** Goal-domain unary methods (mutations only: the read side is the 'goal' session projection). */
+/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
 export interface GoalsApi {
   /** Create and arm a goal. */
   create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):
-  Promise<RpcResponse<{ goal: GoalView }>>
+  Promise<RpcResponse<{ ref: GoalRef }>>
 
   /** Edit objective and/or round cap without changing phase. */
   edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>):
-  Promise<RpcResponse<{ goal: GoalView }>>
+  Promise<RpcResponse<{ ref: GoalRef }>>
 
   /** Pause an active goal and disarm automatic continuation. */
   pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
-  Promise<RpcResponse<{ goal: GoalView }>>
+  Promise<RpcResponse<{ ref: GoalRef }>>
 
   /** Resume and arm a stopped goal. */
   resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
-  Promise<RpcResponse<{ goal: GoalView }>>
+  Promise<RpcResponse<{ ref: GoalRef }>>
 
   /** Mark a current non-complete goal complete and disarm it. */
   complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):
-  Promise<RpcResponse<{ goal: GoalView }>>
+  Promise<RpcResponse<{ ref: GoalRef }>>
 
   /** Clear the current goal while retaining a durable tombstone and history. */
   clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>):

+ 1 - 1
packages/host/apiproxy/src/api/index.ts

@@ -36,7 +36,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
 export type { CommandsApi, CommandDescriptor } from './commands.ts'
 export type { SkillsApi, SkillEntry } from './skills.ts'
 export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
-export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } from './goals.ts'
+export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
 export type { ApprovalResponsePayload } from './approvals.ts'
 export type { QuestionResponsePayload } from './questions.ts'
 

+ 0 - 2
packages/host/apiproxy/src/fetch/client.ts

@@ -92,7 +92,6 @@ export interface IApiClient {
     host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
   }
   goals: {
-    get(payload: RequestPayload<'goal.get'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.get'>>>
     create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.create'>>>
     edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.edit'>>>
     pause(payload: RequestPayload<'goal.pause'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.pause'>>>
@@ -353,7 +352,6 @@ export abstract class AbstractApiClient implements IApiClient {
   }
 
   readonly goals: IApiClient['goals'] = {
-    get: (payload, signal) => this.callUnary('goal.get', payload, signal),
     create: (payload, signal) => this.callUnary('goal.create', payload, signal),
     edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
     pause: (payload, signal) => this.callUnary('goal.pause', payload, signal),

+ 2 - 0
packages/host/apiproxy/src/index.ts

@@ -57,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
   readonly workspace: ApiProxy['workspace']
   readonly host: ApiProxy['host']
   readonly commands: ApiProxy['commands']
+  readonly goals: ApiProxy['goals']
   readonly skills: ApiProxy['skills']
   readonly events: ApiProxy['events']
   readonly respond: ApiProxy['respond']
@@ -74,6 +75,7 @@ export class ApiProxyService extends Service implements ApiProxy {
     this.workspace = api.workspace
     this.host = api.host
     this.commands = api.commands
+    this.goals = api.goals
     this.skills = api.skills
     this.events = api.events
     // createApiProxy returns closures (no `this` capture); bind only satisfies

+ 18 - 38
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -7,7 +7,7 @@
 
 import { describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-session'
-import type { ApiProxy, GoalRef, GoalView, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
+import type { ApiProxy, GoalRef, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
 import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
 
 const sid = (id: string): SessionId => id as SessionId
@@ -418,19 +418,9 @@ describe('SSE stream path', () => {
 })
 
 describe('goals unary surface', () => {
-  /** A wire-valid GoalView the scripted impl hands back. */
-  const view: GoalView = {
-    id: 'goal-1' as GoalView['id'],
-    revision: 2,
-    objective: 'ship it',
-    phase: 'active' as const,
-    maxGoalRounds: 4,
-    roundsStarted: 1,
-    createdAt: 10,
-    updatedAt: 20,
-    activation: 'armed' as const,
-  }
   const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 }
+  /** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */
+  const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } }
 
   it('round-trips every goal method with its own payload and value shape', async () => {
     const seen: { method: string; payload: unknown }[] = []
@@ -441,43 +431,33 @@ describe('goals unary surface', () => {
       }
     const api = scriptedApi({
       goals: {
-        get: record('goal.get', r => ok(r, { goal: view })),
-        create: record('goal.create', r => ok(r, { goal: view })),
-        edit: record('goal.edit', r => ok(r, { goal: { ...view, revision: 3 } })),
-        pause: record('goal.pause', r => ok(r, { goal: { ...view, phase: 'paused' as const, activation: 'disarmed' as const } })),
-        resume: record('goal.resume', r => ok(r, { goal: view })),
-        complete: record('goal.complete', r => ok(r, { goal: { ...view, phase: 'complete' as const, activation: 'disarmed' as const } })),
+        create: record('goal.create', r => ok(r, ack)),
+        edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })),
+        pause: record('goal.pause', r => ok(r, ack)),
+        resume: record('goal.resume', r => ok(r, ack)),
+        complete: record('goal.complete', r => ok(r, ack)),
         clear: record('goal.clear', r => ok(r, { cleared: true as const })),
       },
     })
     const c = client(api)
 
-    const got = await c.goals.get({ sessionId: sid('s1') })
-    expect(got.result).toEqual({ ok: true, value: { goal: view } })
     const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 })
-    expect(created.result).toEqual({ ok: true, value: { goal: view } })
+    expect(created.result).toEqual({ ok: true, value: ack })
     const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' })
-    expect(edited.result).toEqual({ ok: true, value: { goal: { ...view, revision: 3 } } })
-    const paused = await c.goals.pause({ sessionId: sid('s1'), ref })
-    expect(paused.result.ok && paused.result.value.goal.phase).toBe('paused')
-    const resumed = await c.goals.resume({ sessionId: sid('s1'), ref })
-    expect(resumed.result.ok && resumed.result.value.goal.phase).toBe('active')
-    const completed = await c.goals.complete({ sessionId: sid('s1'), ref })
-    expect(completed.result.ok && completed.result.value.goal.phase).toBe('complete')
+    expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } })
+    expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
+    expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
+    expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack })
     const cleared = await c.goals.clear({ sessionId: sid('s1'), ref })
     expect(cleared.result).toEqual({ ok: true, value: { cleared: true } })
 
     // The handler dispatched each call through its own route row: payload parsed per method.
-    expect(seen.map(s => s.method)).toEqual(['goal.get', 'goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
-    expect(seen[1]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
-    expect(seen[2]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
+    expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear'])
+    expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 })
+    expect(seen[1]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' })
   })
 
-  it('passes a null goal and business errors through the goal.get route', async () => {
-    const api = scriptedApi({ goals: { get: r => ok(r, { goal: null }) } })
-    const response = await client(api).goals.get({ sessionId: sid('s-empty') })
-    expect(response.result).toEqual({ ok: true, value: { goal: null } })
-
+  it('passes business errors through as results, not throws', async () => {
     // Default scripted goals impl answers an err result: it must arrive as a result, not a throw.
     const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref })
     expect(failed.result.ok).toBe(false)
@@ -490,7 +470,7 @@ describe('goals unary surface', () => {
     if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
 
     let editCalls = 0
-    const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, { goal: view }) } } })
+    const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, ack) } } })
     const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref })
     expect(emptyEdit.result.ok).toBe(false)
     if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request')

+ 0 - 3
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -136,9 +136,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
       },
     },
     goals: {
-      async get(request) {
-        return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
-      },
       async create(request) {
         return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } }
       },

+ 3 - 0
packages/host/apiproxy/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../goal/goal"
+    },
     {
       "path": "../../../vendor/cordis"
     },