import type { Approval, CaseKind, DecisionCase, ExecutionPermit, Proposal, ToolCallRef } from '@vpp/domain' import { Approval as ApprovalSchema, DecisionCase as DecisionCaseSchema, Proposal as ProposalSchema } from '@vpp/domain' import type { AuthorityService } from './authority.js' import type { EventBus } from './events.js' import { MemoryRepository } from './relational.js' import type { Repository } from './relational.js' import type { SnapshotStore } from './snapshot.js' /** * Case Desk (docs/02 §5, ADR-0008): the operator's unit of accountability. * A case references proposals/approvals/permits by id — never copies — and * the inbox and case view are projections over those stores plus the event * log. Approval itself is a workflow resume (docs/09 §2); the desk only * records the human decision and hands it to the runtime's resume hook. */ export interface PendingApproval { proposal_id: string proposal_digest: string case_id: string run_id: string required_level: string reasons: string[] since: string } export interface CaseView { case: DecisionCase proposals: Array<{ proposal: Proposal; pending: PendingApproval | null }> approvals: Approval[] permits: ExecutionPermit[] events: Array<{ event_id: string; event_type: string; occurred_at: string }> } export interface LineageExpansion { proposal_id: string digest: string ledger_version: number policy_pack_version: string tool_calls: Array data_refs: Array<{ ref: string; present: boolean }> } export interface CaseDeskDeps { cases?: Repository proposals?: Repository approvals?: Repository pending?: Repository authority: AuthorityService snapshots: SnapshotStore events: EventBus clock: () => string } export class CaseDeskService { readonly cases: Repository readonly proposals: Repository readonly approvals: Repository readonly pending: Repository constructor(private readonly deps: CaseDeskDeps) { this.cases = deps.cases ?? new MemoryRepository(DecisionCaseSchema, (c) => c.id) this.proposals = deps.proposals ?? new MemoryRepository(ProposalSchema, (p) => p.id) this.approvals = deps.approvals ?? new MemoryRepository(ApprovalSchema, (a) => a.id) this.pending = deps.pending ?? new MemoryRepository(PendingApprovalSchema, (p) => p.proposal_id) } open(input: { id: string; kind: CaseKind; objective: string; owner: string; deadline: string }): DecisionCase { const now = this.deps.clock() const c: DecisionCase = { id: input.id, kind: input.kind, objective: input.objective, owner: input.owner, deadline: input.deadline, status: 'OPEN', refs: { evidence: [], proposals: [], approvals: [], permits: [], scenarios: [], outcome: null }, opened_at: now, closed_at: null, } this.cases.put(c) this.deps.events.append({ event_type: 'CaseOpened', payload: { case_id: c.id, kind: c.kind }, correlation_id: c.id }) return c } private update(caseId: string, fn: (c: DecisionCase) => DecisionCase): DecisionCase { const row = this.cases.get(caseId) if (!row) throw new Error(`case ${caseId} not found`) return this.cases.put(fn(structuredClone(row.value)), row.version).value } addEvidence(caseId: string, refs: string[]): void { this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, evidence: [...new Set([...c.refs.evidence, ...refs])] }, status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status })) } /** Called by the lifecycle workflow whenever the proposal changes status. */ recordProposal(caseId: string, proposal: Proposal): void { this.proposals.put(proposal) this.update(caseId, (c) => ({ ...c, status: c.status === 'OPEN' ? 'IN_PROGRESS' : c.status, refs: { ...c.refs, proposals: [...new Set([...c.refs.proposals, proposal.id])] }, })) this.deps.events.append({ event_type: 'ProposalStatusChanged', payload: { proposal_id: proposal.id, digest: proposal.digest, status: proposal.status }, correlation_id: caseId, causation_id: proposal.id }) } /** Lifecycle suspended at the envelope gate: surface it in the inbox. */ requestApproval(p: PendingApproval): void { this.pending.put(p) this.update(p.case_id, (c) => ({ ...c, status: 'AWAITING_APPROVAL' })) this.deps.events.append({ event_type: 'ApprovalRequested', payload: p, correlation_id: p.case_id, causation_id: p.proposal_id }) } inbox(): PendingApproval[] { return this.pending.list().map((r) => r.value).sort((a, b) => (a.since < b.since ? -1 : 1)) } recordApproval(caseId: string, approval: Approval): void { this.approvals.put(approval) const pending = this.pending.list().find((r) => r.value.proposal_digest === approval.proposal_digest) if (pending) this.pending.put({ ...pending.value, run_id: '' }) // keep for audit; runtime clears this.update(caseId, (c) => ({ ...c, status: 'IN_PROGRESS', refs: { ...c.refs, approvals: [...new Set([...c.refs.approvals, approval.id])] } })) this.deps.events.append({ event_type: 'HumanDecision', payload: approval, correlation_id: caseId, causation_id: approval.proposal_digest }) } clearPending(proposalId: string): void { const row = this.pending.get(proposalId) if (row) this.pending.put({ ...row.value, run_id: '' }, row.version) // MemoryRepository has no delete; a cleared entry is one with run_id ''. } recordPermit(caseId: string, permit: ExecutionPermit): void { this.update(caseId, (c) => ({ ...c, refs: { ...c.refs, permits: [...new Set([...c.refs.permits, permit.id])] } })) } close(caseId: string, outcome: string | null, done: boolean): DecisionCase { const c = this.update(caseId, (c) => ({ ...c, status: done ? 'CLOSED_DONE' : 'CLOSED_ABORTED', refs: { ...c.refs, outcome }, closed_at: this.deps.clock() })) this.deps.events.append({ event_type: 'CaseClosed', payload: { case_id: caseId, status: c.status, outcome }, correlation_id: caseId }) return c } read(caseId: string): CaseView { const row = this.cases.get(caseId) if (!row) throw new Error(`case ${caseId} not found`) const c = row.value const pendingByProposal = new Map(this.pending.list().filter((r) => r.value.run_id !== '').map((r) => [r.value.proposal_id, r.value])) return { case: c, proposals: c.refs.proposals.map((id) => ({ proposal: this.proposals.get(id)!.value, pending: pendingByProposal.get(id) ?? null })), approvals: c.refs.approvals.map((id) => this.approvals.get(id)!.value), permits: c.refs.permits.map((id) => this.deps.authority.permits.get(id)!.value), events: this.deps.events.list({ correlation_id: caseId }).map((e) => ({ event_id: e.event_id, event_type: e.event_type, occurred_at: e.occurred_at })), } } /** P5 in the UI: conclusion → tool outputs → data sources. */ expandLineage(proposalId: string): LineageExpansion { const row = this.proposals.get(proposalId) if (!row) throw new Error(`proposal ${proposalId} not found`) const p = row.value return { proposal_id: p.id, digest: p.digest, ledger_version: p.lineage.ledger_version, policy_pack_version: p.lineage.policy_pack_version, tool_calls: p.lineage.tool_calls.map((tc) => ({ ...tc, inputs: this.deps.snapshots.get(tc.inputs_ref), outputs: this.deps.snapshots.get(tc.outputs_ref) })), data_refs: p.lineage.data_refs.map((ref) => ({ ref, present: this.deps.snapshots.has(ref) })), } } } import { z } from 'zod' export const PendingApprovalSchema = z.object({ proposal_id: z.string().min(1), proposal_digest: z.string().min(1), case_id: z.string().min(1), run_id: z.string(), required_level: z.string().min(1), reasons: z.array(z.string()), since: z.string().min(1), })