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
|
|
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
M4: resource agent, envelopes live, review loop, insight cards
- packages/domain: AwardNotice, DISPATCH_PLAN proposal payload, ExecutionReport,
MeteringRecord, potential-assessment and dispatch-optimization contracts,
ReviewFinding (+ typed writebacks), SemanticMemoryEntry,
EnvelopeChangeRequest, InsightCard — exported with fixtures on both sides.
- skills-py: potential-assessment (certified × rolling fulfilment, evidence
days) and dispatch-optimization (per-interval LP on HiGHS, shortfall
reported) skills + routes + tests.
- packages/services: dispatch rules in the policy pack (over-allocation,
award anchor, lineage integrity for allocations); PowerBalanceSimulator;
SimulationGateway (permit-only, idempotent, seeded execute → ExecutionReports);
envelope deviation-streak suspension + apply(); ReviewService (attribution,
reliability EWMA writeback, semantic memory, envelope recommendations as
change requests); dispatch assembler; skill client methods.
- packages/runtime: resource agent; award-decomposition, review and
envelope-review workflows; lifecycle selects simulator/gateway by proposal
type; trigger hooks for awards, execution reports, metering; decide()
resumes either lifecycle or envelope-review runs; insight cards API.
- Tests: docs/07 D-1 16:00 and D+1 end to end; reliability score 0.9 → 0.880
and the next assessment de-rates capacity; envelope suspension on a seeded
3-day streak; WIDEN request applied only by a human. 184 TS + 80 Python.
- docs/open-questions: B10 (reliability/potential parameters). README and
CLAUDE.md status → M4 done, M5 next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 19:31:12 -04:00
|
|
|
import type { BidPayload, BidProposalDraft, DispatchPlanPayload, DispatchProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
|
|
|
|
|
import { BidPayload as BidPayloadSchema, Curve96, DecimalString, DispatchPlanPayload as DispatchPlanPayloadSchema } from '@vpp/domain'
|
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
|
|
|
import { proposalDigest } from './digest.js'
|
|
|
|
|
import type { SnapshotStore } from './snapshot.js'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lineage recorder (docs/09 §4): every skill call records
|
|
|
|
|
* {tool, version, inputs_ref, outputs_ref}; the buffer travels with the
|
|
|
|
|
* workflow run via AsyncLocalStorage and is frozen into the Proposal.
|
|
|
|
|
*/
|
|
|
|
|
export class LineageRecorder {
|
|
|
|
|
private readonly als = new AsyncLocalStorage<{ calls: ToolCallRef[]; dataRefs: Set<string> }>()
|
|
|
|
|
|
|
|
|
|
run<T>(fn: () => T): T {
|
|
|
|
|
return this.als.run({ calls: [], dataRefs: new Set() }, fn)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
record(call: ToolCallRef): void {
|
|
|
|
|
const store = this.als.getStore()
|
|
|
|
|
if (!store) throw new Error('lineage.record called outside a lineage.run scope')
|
|
|
|
|
store.calls.push(call)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addDataRef(ref: string): void {
|
|
|
|
|
this.als.getStore()?.dataRefs.add(ref)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
current(): { tool_calls: ToolCallRef[]; data_refs: string[] } {
|
|
|
|
|
const store = this.als.getStore()
|
|
|
|
|
if (!store) throw new Error('lineage.current called outside a lineage.run scope')
|
|
|
|
|
return { tool_calls: [...store.calls], data_refs: [...store.dataRefs].sort() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Wrap any deterministic skill call so it is snapshotted and recorded. */
|
|
|
|
|
export function withLineage<I, O>(
|
|
|
|
|
lineage: LineageRecorder,
|
|
|
|
|
snapshots: SnapshotStore,
|
|
|
|
|
def: { tool: string; version: string; idGen: () => string },
|
|
|
|
|
call: (input: I) => O,
|
|
|
|
|
): (input: I) => { output: O; call: ToolCallRef } {
|
|
|
|
|
return (input: I) => {
|
|
|
|
|
const inputs_ref = snapshots.put(input)
|
|
|
|
|
const output = call(input)
|
|
|
|
|
const outputs_ref = snapshots.put(output)
|
|
|
|
|
const ref: ToolCallRef = { tool_call_id: def.idGen(), tool: def.tool, version: def.version, inputs_ref, outputs_ref }
|
|
|
|
|
lineage.record(ref)
|
|
|
|
|
return { output, call: ref }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class LineageRefError extends Error {
|
|
|
|
|
constructor(message: string) {
|
|
|
|
|
super(message)
|
|
|
|
|
this.name = 'LineageRefError'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Dereference {tool_call_id, path} into the recorded output snapshot. */
|
|
|
|
|
export function resolveRef(ref: LineageRef, calls: ToolCallRef[], snapshots: SnapshotStore): unknown {
|
|
|
|
|
const call = calls.find((c) => c.tool_call_id === ref.tool_call_id)
|
|
|
|
|
if (!call) throw new LineageRefError(`tool call ${ref.tool_call_id} is not in this proposal's lineage`)
|
|
|
|
|
const output = snapshots.get(call.outputs_ref)
|
|
|
|
|
if (output === undefined) throw new LineageRefError(`output snapshot for ${ref.tool_call_id} missing`)
|
|
|
|
|
let node: unknown = output
|
|
|
|
|
for (const part of ref.path.split('.')) {
|
|
|
|
|
if (node === null || typeof node !== 'object') throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`)
|
|
|
|
|
node = Array.isArray(node) ? node[Number(part)] : (node as Record<string, unknown>)[part]
|
|
|
|
|
if (node === undefined) throw new LineageRefError(`path ${ref.path} does not resolve in ${ref.tool_call_id}`)
|
|
|
|
|
}
|
|
|
|
|
return node
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The P2 assembler (docs/09 §3): builds a Proposal from an LLM draft that
|
|
|
|
|
* contains only references. Numbers are dereferenced from lineage snapshots;
|
|
|
|
|
* a draft with a literal number is rejected by the schema before we get here.
|
|
|
|
|
*/
|
|
|
|
|
export function assembleBidProposal(
|
|
|
|
|
draft: BidProposalDraft,
|
|
|
|
|
ctx: {
|
|
|
|
|
id: string
|
|
|
|
|
originator: Proposal['originator']
|
|
|
|
|
lineage: { tool_calls: ToolCallRef[]; data_refs: string[] }
|
|
|
|
|
ledger_version: number
|
|
|
|
|
policy_pack_version: string
|
|
|
|
|
snapshots: SnapshotStore
|
|
|
|
|
now: string
|
|
|
|
|
},
|
|
|
|
|
): Proposal {
|
|
|
|
|
const prices = Curve96.parse(resolveRef(draft.prices_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
|
|
|
|
const quantities = Curve96.parse(resolveRef(draft.quantities_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
|
|
|
|
const revenue = DecimalString.parse(resolveRef(draft.expected_revenue_ref, ctx.lineage.tool_calls, ctx.snapshots))
|
|
|
|
|
const payload: BidPayload = BidPayloadSchema.parse({
|
|
|
|
|
kind: 'BID',
|
|
|
|
|
market_date: draft.market_date,
|
|
|
|
|
prices_yuan_per_mwh: prices,
|
|
|
|
|
quantities_mwh: quantities,
|
|
|
|
|
expected_revenue_yuan: revenue,
|
|
|
|
|
})
|
|
|
|
|
const lineage = {
|
|
|
|
|
tool_calls: ctx.lineage.tool_calls,
|
|
|
|
|
data_refs: ctx.lineage.data_refs,
|
|
|
|
|
ledger_version: ctx.ledger_version,
|
|
|
|
|
policy_pack_version: ctx.policy_pack_version,
|
|
|
|
|
}
|
|
|
|
|
const base = {
|
|
|
|
|
id: ctx.id,
|
|
|
|
|
type: 'BID' as const,
|
|
|
|
|
timescale: 'DAY_AHEAD' as const,
|
|
|
|
|
originator: ctx.originator,
|
|
|
|
|
payload,
|
|
|
|
|
lineage,
|
|
|
|
|
envelope_ref: null,
|
|
|
|
|
status: 'DRAFT' as const,
|
|
|
|
|
created_at: ctx.now,
|
|
|
|
|
}
|
|
|
|
|
return { ...base, digest: proposalDigest(base) }
|
|
|
|
|
}
|
M4: resource agent, envelopes live, review loop, insight cards
- packages/domain: AwardNotice, DISPATCH_PLAN proposal payload, ExecutionReport,
MeteringRecord, potential-assessment and dispatch-optimization contracts,
ReviewFinding (+ typed writebacks), SemanticMemoryEntry,
EnvelopeChangeRequest, InsightCard — exported with fixtures on both sides.
- skills-py: potential-assessment (certified × rolling fulfilment, evidence
days) and dispatch-optimization (per-interval LP on HiGHS, shortfall
reported) skills + routes + tests.
- packages/services: dispatch rules in the policy pack (over-allocation,
award anchor, lineage integrity for allocations); PowerBalanceSimulator;
SimulationGateway (permit-only, idempotent, seeded execute → ExecutionReports);
envelope deviation-streak suspension + apply(); ReviewService (attribution,
reliability EWMA writeback, semantic memory, envelope recommendations as
change requests); dispatch assembler; skill client methods.
- packages/runtime: resource agent; award-decomposition, review and
envelope-review workflows; lifecycle selects simulator/gateway by proposal
type; trigger hooks for awards, execution reports, metering; decide()
resumes either lifecycle or envelope-review runs; insight cards API.
- Tests: docs/07 D-1 16:00 and D+1 end to end; reliability score 0.9 → 0.880
and the next assessment de-rates capacity; envelope suspension on a seeded
3-day streak; WIDEN request applied only by a human. 184 TS + 80 Python.
- docs/open-questions: B10 (reliability/potential parameters). README and
CLAUDE.md status → M4 done, M5 next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoYoGYzHkFyv3ALenkRPhA
2026-09-02 19:31:12 -04:00
|
|
|
|
|
|
|
|
/** DISPATCH_PLAN assembler: same discipline, resource-agent originator, EVENT trigger. */
|
|
|
|
|
export function assembleDispatchProposal(
|
|
|
|
|
draft: DispatchProposalDraft,
|
|
|
|
|
ctx: {
|
|
|
|
|
id: string
|
|
|
|
|
award_ref: string
|
|
|
|
|
originator: Proposal['originator']
|
|
|
|
|
lineage: { tool_calls: ToolCallRef[]; data_refs: string[] }
|
|
|
|
|
ledger_version: number
|
|
|
|
|
policy_pack_version: string
|
|
|
|
|
snapshots: SnapshotStore
|
|
|
|
|
now: string
|
|
|
|
|
},
|
|
|
|
|
): Proposal {
|
|
|
|
|
const payload: DispatchPlanPayload = DispatchPlanPayloadSchema.parse({
|
|
|
|
|
kind: 'DISPATCH_PLAN',
|
|
|
|
|
market_date: draft.market_date,
|
|
|
|
|
award_ref: ctx.award_ref,
|
|
|
|
|
total_target_mw: Curve96.parse(resolveRef(draft.total_target_ref, ctx.lineage.tool_calls, ctx.snapshots)),
|
|
|
|
|
allocations: resolveRef(draft.allocations_ref, ctx.lineage.tool_calls, ctx.snapshots),
|
|
|
|
|
})
|
|
|
|
|
const base = {
|
|
|
|
|
id: ctx.id,
|
|
|
|
|
type: 'DISPATCH_PLAN' as const,
|
|
|
|
|
timescale: 'DAY_AHEAD' as const,
|
|
|
|
|
originator: ctx.originator,
|
|
|
|
|
payload,
|
|
|
|
|
lineage: { tool_calls: ctx.lineage.tool_calls, data_refs: ctx.lineage.data_refs, ledger_version: ctx.ledger_version, policy_pack_version: ctx.policy_pack_version },
|
|
|
|
|
envelope_ref: null,
|
|
|
|
|
status: 'DRAFT' as const,
|
|
|
|
|
created_at: ctx.now,
|
|
|
|
|
}
|
|
|
|
|
return { ...base, digest: proposalDigest(base) }
|
|
|
|
|
}
|