vpp-ai-platform/packages/services/src/casedesk.ts
Thomas Bayes 1cc21e0dc6
Some checks are pending
ci / typescript (push) Waiting to run
ci / python (push) Waiting to run
ci / evals (push) Blocked by required conditions
M3: Mastra runtime, safety chain, two agents, Case Desk v1
- packages/domain: safety-chain objects (ValidationResult, SimulationResult,
  EnvelopeMatch, StaleDenial, ExecutionReceipt, LineageRef/BidProposalDraft,
  RouterDecision, BidExportFile) + fixtures on both sides.
- packages/services: proposal digest; PolicyEngine + hubei-spot-bidding pack
  (digest-valid, bid-format, price-limits, quantity-non-negative,
  ledger-consistency, lineage-integrity, originator-permission — each with
  pass/fail tests); EnvelopeService; AuthorityService (fresh check, permits,
  revoke, gateway validate); FileExportGateway (idempotent receipts);
  Memory/File EventBus; LineageRecorder + P2 assembler; RevenueScenario
  simulator; CaseDeskService; FsRepository; skill HTTP client moved here.
- packages/runtime: createRuntime (LibSQL storage, per-runtime workflow
  factories), proposal-lifecycle (rule check → simulation → envelope gate
  with suspend/resume → fresh check + permit → release), day-ahead-situation,
  day-ahead-bid, TriggerService (scheduled/event/manual), LlmPort
  (Mastra/Scripted/Null), Case Desk HTTP API, dev entry point.
- Tests: all eight docs/01 invariants, docs/07 06:00→08:30 end to end with
  LLM down, restart survival of a suspended approval, permit expiry and
  revocation, replay of a released proposal, trigger scheduling. 141 TS +
  60 Python tests.
- Known gaps: ledger not yet persisted (replayed on restart); STALE ends the
  run instead of looping to rule check; synthetic data stands in for
  historical replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 06:55:55 -04:00

181 lines
7.8 KiB
TypeScript

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<ToolCallRef & { inputs: unknown; outputs: unknown }>
data_refs: Array<{ ref: string; present: boolean }>
}
export interface CaseDeskDeps {
cases?: Repository<DecisionCase>
proposals?: Repository<Proposal>
approvals?: Repository<Approval>
pending?: Repository<PendingApproval>
authority: AuthorityService
snapshots: SnapshotStore
events: EventBus
clock: () => string
}
export class CaseDeskService {
readonly cases: Repository<DecisionCase>
readonly proposals: Repository<Proposal>
readonly approvals: Repository<Approval>
readonly pending: Repository<PendingApproval>
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),
})