- 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
181 lines
7.8 KiB
TypeScript
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),
|
|
})
|