M3: Mastra runtime, safety chain, two agents, Case Desk v1
Some checks are pending
ci / typescript (push) Waiting to run
ci / python (push) Waiting to run
ci / evals (push) Blocked by required conditions

- 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
This commit is contained in:
Thomas Bayes 2026-09-02 06:55:55 -04:00
parent 8796faca63
commit 1cc21e0dc6
72 changed files with 6934 additions and 29 deletions

View File

@ -6,13 +6,17 @@ docs win — or write an ADR changing the doc first.
## Current phase ## Current phase
Docs complete; M1 and M2 implemented (schemas + contracts pipeline; ledger, Docs complete; M1–M3 implemented: schemas + contracts pipeline; deterministic
snapshot, time-series, relational, ingestion services; Python skill service services (ledger, stores, policy engine, envelope, authority/permits, gateway,
with forecast/MILP/report skills; L2 eval harness with committed baseline). events, lineage assembler, simulation, Case Desk); Python skill service; L2 eval
Storage is in-memory reference semantics — persistent adapters arrive with M3. harness with committed baseline (synthetic data); Mastra runtime with the
The eval dataset is synthetic until historical data lands. Next is M3. Build proposal-lifecycle safety chain (durable suspend/resume), situation and bid
order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric must workflows, trigger service, LLM port with LLM-down mode, Case Desk API. Storage
update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`). is file-backed reference semantics — Postgres/Timescale adapters later. Next is
M4. Build order is ROADMAP.md (M1→M5). A skill change that moves an L2 metric
must update the baseline in the same change (`npm run eval -w @vpp/evals -- --write-baseline`).
Workflows are per-runtime factories (`createProposalLifecycle(ctx)` etc.) — never
module singletons, or a second Mastra instance steals their context.
Do not start a milestone's work before its predecessor's acceptance criteria are Do not start a milestone's work before its predecessor's acceptance criteria are
testable, and do not build phase-2 items (edge control links, federation, testable, and do not build phase-2 items (edge control links, federation,
interaction/load-control agents) unless explicitly asked. interaction/load-control agents) unless explicitly asked.

View File

@ -6,16 +6,28 @@ a deterministic safety chain (rule check → simulation → envelope/human appro
execution permit) governs everything before any external effect. **The LLM never execution permit) governs everything before any external effect. **The LLM never
computes numbers and never touches the second-level control loop.** computes numbers and never touches the second-level control loop.**
**Status: M1 and M2 implemented.** Design docs 00–13 are complete; **Status: M1–M3 implemented.** Design docs 00–13 are complete; implementation
implementation follows [ROADMAP.md](ROADMAP.md). Present today: domain schemas follows [ROADMAP.md](ROADMAP.md). Present today:
(`packages/domain`), the TS↔Python contract pipeline (`contracts/`,
`skills-py/vpp_contracts`), ledger/snapshot/time-series/relational/ingestion - `packages/domain` — zod schemas for every business and safety-chain object, exported to
services (`packages/services`), the Python skill service — load/PV/price `contracts/` and regenerated as pydantic models (`skills-py/vpp_contracts`).
forecasts with calibrated quantiles, bid-optimization MILP, report generator - `packages/services` — deterministic services: ledger (with the P7 cascade), snapshot,
(`skills-py/vpp_skills`) — and the L2 eval harness with a committed baseline time-series, relational and ingestion stores; policy engine + `hubei-spot-bidding` pack;
(`packages/evals`). The eval dataset is **synthetic** (no historical Hubei data envelope gate; authority (fresh check, permits, revocation); file-export gateway;
yet); baselines on it measure the harness, not the KPI. M3 (runtime, agents, event store/bus; lineage recorder + P2 assembler; revenue-scenario simulation; Case Desk.
safety chain) is next. - `skills-py/vpp_skills` — Python skill service: load/PV/price forecasts with calibrated
quantiles, bid-optimization MILP, report generator.
- `packages/evals` — L2 eval harness with a committed baseline (on a **synthetic** dataset).
- `packages/runtime` — Mastra runtime: `proposal-lifecycle` (the safety chain, suspend/resume
for human approval, durable across restarts), `day-ahead-situation`, `day-ahead-bid`;
trigger service (scheduled / event / manual via router agent); provider-abstracted LLM
port with an LLM-down mode; Case Desk HTTP API. Bid release is a file export for manual
upload (degraded channel by design).
All eight docs/01 invariants have automated tests (`packages/services/test/chain.test.ts`,
`packages/runtime/test/lifecycle.test.ts`). Storage is file-backed reference semantics;
Postgres/Timescale adapters and the programmatic trading-platform channel are later work.
M4 (resource agent, envelopes live, review loop) is next.
## Development ## Development
@ -26,6 +38,7 @@ pydantic models use `StrEnum` and PEP 604 unions).
npm ci npm ci
npm run check # typecheck, re-export contracts, run TS tests npm run check # typecheck, re-export contracts, run TS tests
npm run eval -w @vpp/evals -- --check # L2 harness vs baseline (needs the skill service below) npm run eval -w @vpp/evals -- --check # L2 harness vs baseline (needs the skill service below)
npm run start -w @vpp/runtime # runtime + Case Desk API on :4100 (VPP_LLM_MODEL unset = LLM-down mode)
cd skills-py cd skills-py
uv venv --python 3.11 .venv && uv pip install -r requirements.txt # or python3.11 -m venv uv venv --python 3.11 .venv && uv pip install -r requirements.txt # or python3.11 -m venv

View File

@ -0,0 +1,13 @@
{
"market_date": "2026-03-15",
"prices_ref": {
"tool_call_id": "tc-001",
"path": "prices_yuan_per_mwh"
},
"quantities_ref": {
"tool_call_id": "tc-001",
"path": "quantities_mwh"
},
"expected_revenue_ref": "510600.00",
"rationale": "x"
}

View File

@ -0,0 +1,7 @@
{
"workflow_id": "transfer-funds",
"params": {
"market_date": "2026-03-15"
},
"confidence": "HIGH"
}

View File

@ -0,0 +1,213 @@
{
"format_version": "1.0.0",
"proposal_id": "prop-001",
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"permit_id": "permit-001",
"proposal_type": "BID",
"market_date": "2026-03-15",
"prices_yuan_per_mwh": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00",
"0.00"
]
},
"quantities_mwh": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5",
"12.5"
]
},
"exported_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,16 @@
{
"market_date": "2026-03-15",
"prices_ref": {
"tool_call_id": "tc-001",
"path": "prices_yuan_per_mwh"
},
"quantities_ref": {
"tool_call_id": "tc-001",
"path": "quantities_mwh"
},
"expected_revenue_ref": {
"tool_call_id": "tc-001",
"path": "expected_revenue_yuan"
},
"rationale": "Evening peak priced above P50; allocate bounded energy to 18:00–20:00."
}

View File

@ -0,0 +1,10 @@
{
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"within": false,
"envelope_id": null,
"required_level": "L2",
"reasons": [
"no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01"
],
"checked_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,8 @@
{
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"within": true,
"envelope_id": "env-bid-001",
"required_level": "L1",
"reasons": [],
"checked_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,9 @@
{
"receipt_id": "rcpt-001",
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"permit_id": "permit-001",
"channel": "FILE_EXPORT",
"idempotency_key": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:permit-001",
"artifact_ref": "exports/bid-2026-03-15-prop-001.json",
"accepted_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,7 @@
{
"workflow_id": "day-ahead-bid",
"params": {
"market_date": "2026-03-15"
},
"confidence": "HIGH"
}

View File

@ -0,0 +1,18 @@
{
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kind": "REVENUE_SCENARIOS",
"alert": false,
"alerts": [],
"metrics": {
"revenue_p05_yuan": "410200.00",
"revenue_p50_yuan": "510600.00",
"worst_loss_yuan": "0.00"
},
"scenario_count": 1000,
"simulator": {
"name": "revenue-scenarios",
"version": "1.0.0",
"seed": 20260315
},
"simulated_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,7 @@
{
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"reasons": [
"ledger version 42 at approval, now 43"
],
"checked_at": "2026-03-15T23:59:59Z"
}

View File

@ -0,0 +1,16 @@
{
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"policy_pack": {
"id": "hubei-spot-bidding",
"version": "2026.03"
},
"ok": true,
"violations": [],
"rules_evaluated": [
"bid-format",
"price-limits",
"ledger-consistency",
"lineage-integrity"
],
"checked_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,20 @@
{
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"policy_pack": {
"id": "hubei-spot-bidding",
"version": "2026.03"
},
"ok": false,
"violations": [
{
"rule_id": "price-limits",
"severity": "REJECT",
"message": "price 1500.00 above cap"
}
],
"rules_evaluated": [
"bid-format",
"price-limits"
],
"checked_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,110 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"format_version": {
"type": "string",
"const": "1.0.0"
},
"proposal_id": {
"type": "string",
"minLength": 1
},
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"permit_id": {
"type": "string",
"minLength": 1
},
"proposal_type": {
"type": "string",
"enum": [
"BID",
"INVITATION",
"CONTROL_PLAN",
"DISPATCH_PLAN"
]
},
"market_date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"prices_yuan_per_mwh": {
"type": "object",
"properties": {
"interval_minutes": {
"type": "number",
"const": 15
},
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"values": {
"minItems": 96,
"maxItems": 96,
"type": "array",
"items": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
}
},
"required": [
"interval_minutes",
"date",
"values"
],
"additionalProperties": false
},
"quantities_mwh": {
"type": "object",
"properties": {
"interval_minutes": {
"type": "number",
"const": 15
},
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"values": {
"minItems": 96,
"maxItems": 96,
"type": "array",
"items": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
}
},
"required": [
"interval_minutes",
"date",
"values"
],
"additionalProperties": false
},
"exported_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"format_version",
"proposal_id",
"proposal_digest",
"permit_id",
"proposal_type",
"market_date",
"prices_yuan_per_mwh",
"quantities_mwh",
"exported_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/bid_export_file.json",
"title": "BidExportFile"
}

View File

@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"market_date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"prices_ref": {
"type": "object",
"properties": {
"tool_call_id": {
"type": "string",
"minLength": 1
},
"path": {
"type": "string",
"minLength": 1
}
},
"required": [
"tool_call_id",
"path"
],
"additionalProperties": false
},
"quantities_ref": {
"type": "object",
"properties": {
"tool_call_id": {
"type": "string",
"minLength": 1
},
"path": {
"type": "string",
"minLength": 1
}
},
"required": [
"tool_call_id",
"path"
],
"additionalProperties": false
},
"expected_revenue_ref": {
"type": "object",
"properties": {
"tool_call_id": {
"type": "string",
"minLength": 1
},
"path": {
"type": "string",
"minLength": 1
}
},
"required": [
"tool_call_id",
"path"
],
"additionalProperties": false
},
"rationale": {
"type": "string",
"minLength": 1
}
},
"required": [
"market_date",
"prices_ref",
"quantities_ref",
"expected_revenue_ref",
"rationale"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/bid_proposal_draft.json",
"title": "BidProposalDraft"
}

View File

@ -0,0 +1,54 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"within": {
"type": "boolean"
},
"envelope_id": {
"anyOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "null"
}
]
},
"required_level": {
"type": "string",
"enum": [
"L1",
"L2",
"L3"
]
},
"reasons": {
"type": "array",
"items": {
"type": "string"
}
},
"checked_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"proposal_digest",
"within",
"envelope_id",
"required_level",
"reasons",
"checked_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/envelope_match.json",
"title": "EnvelopeMatch"
}

View File

@ -0,0 +1,51 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"receipt_id": {
"type": "string",
"minLength": 1
},
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"permit_id": {
"type": "string",
"minLength": 1
},
"channel": {
"type": "string",
"enum": [
"FILE_EXPORT",
"TRADING_PLATFORM_API",
"SIMULATION_GATEWAY"
]
},
"idempotency_key": {
"type": "string",
"minLength": 1
},
"artifact_ref": {
"type": "string",
"minLength": 1
},
"accepted_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"receipt_id",
"proposal_digest",
"permit_id",
"channel",
"idempotency_key",
"artifact_ref",
"accepted_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/execution_receipt.json",
"title": "ExecutionReceipt"
}

View File

@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"workflow_id": {
"type": "string",
"enum": [
"day-ahead-situation",
"day-ahead-bid",
"adhoc-analysis"
]
},
"params": {
"type": "object",
"properties": {
"market_date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
}
},
"required": [
"market_date"
],
"additionalProperties": false
},
"confidence": {
"type": "string",
"enum": [
"HIGH",
"MEDIUM",
"LOW"
]
}
},
"required": [
"workflow_id",
"params",
"confidence"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/router_decision.json",
"title": "RouterDecision"
}

View File

@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"kind": {
"type": "string",
"enum": [
"REVENUE_SCENARIOS",
"POWER_BALANCE"
]
},
"alert": {
"type": "boolean"
},
"alerts": {
"type": "array",
"items": {
"type": "string"
}
},
"metrics": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
},
"scenario_count": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"simulator": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"minLength": 1
},
"seed": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
}
},
"required": [
"name",
"version",
"seed"
],
"additionalProperties": false
},
"simulated_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"proposal_digest",
"kind",
"alert",
"alerts",
"metrics",
"scenario_count",
"simulator",
"simulated_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/simulation_result.json",
"title": "SimulationResult"
}

View File

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"reasons": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"checked_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"proposal_digest",
"reasons",
"checked_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/stale_denial.json",
"title": "StaleDenial"
}

View File

@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"policy_pack": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"minLength": 1
}
},
"required": [
"id",
"version"
],
"additionalProperties": false
},
"ok": {
"type": "boolean"
},
"violations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"rule_id": {
"type": "string",
"minLength": 1
},
"severity": {
"type": "string",
"enum": [
"REJECT",
"WARN"
]
},
"message": {
"type": "string",
"minLength": 1
}
},
"required": [
"rule_id",
"severity",
"message"
],
"additionalProperties": false
}
},
"rules_evaluated": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"checked_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": [
"proposal_digest",
"policy_pack",
"ok",
"violations",
"rules_evaluated",
"checked_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/validation_result.json",
"title": "ValidationResult"
}

2410
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -248,6 +248,101 @@ const valid: Record<string, Record<string, unknown>> = {
generated_at: T1, generated_at: T1,
}, },
}, },
validation_result: {
pass: {
proposal_digest: REF_B,
policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' },
ok: true,
violations: [],
rules_evaluated: ['bid-format', 'price-limits', 'ledger-consistency', 'lineage-integrity'],
checked_at: T1,
},
'reject-price': {
proposal_digest: REF_B,
policy_pack: { id: 'hubei-spot-bidding', version: '2026.03' },
ok: false,
violations: [{ rule_id: 'price-limits', severity: 'REJECT', message: 'price 1500.00 above cap' }],
rules_evaluated: ['bid-format', 'price-limits'],
checked_at: T1,
},
},
simulation_result: {
'revenue-ok': {
proposal_digest: REF_B,
kind: 'REVENUE_SCENARIOS',
alert: false,
alerts: [],
metrics: { revenue_p05_yuan: '410200.00', revenue_p50_yuan: '510600.00', worst_loss_yuan: '0.00' },
scenario_count: 1000,
simulator: { name: 'revenue-scenarios', version: '1.0.0', seed: 20260315 },
simulated_at: T1,
},
},
envelope_match: {
within: {
proposal_digest: REF_B,
within: true,
envelope_id: 'env-bid-001',
required_level: 'L1',
reasons: [],
checked_at: T1,
},
outside: {
proposal_digest: REF_B,
within: false,
envelope_id: null,
required_level: 'L2',
reasons: ['no ACTIVE envelope covers BID/DAY_AHEAD for pool-hubei-01'],
checked_at: T1,
},
},
stale_denial: {
'ledger-moved': {
proposal_digest: REF_B,
reasons: ['ledger version 42 at approval, now 43'],
checked_at: T2,
},
},
execution_receipt: {
'file-export': {
receipt_id: 'rcpt-001',
proposal_digest: REF_B,
permit_id: 'permit-001',
channel: 'FILE_EXPORT',
idempotency_key: `${REF_B}:permit-001`,
artifact_ref: 'exports/bid-2026-03-15-prop-001.json',
accepted_at: T1,
},
},
bid_proposal_draft: {
basic: {
market_date: DATE,
prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' },
quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' },
expected_revenue_ref: { tool_call_id: 'tc-001', path: 'expected_revenue_yuan' },
rationale: 'Evening peak priced above P50; allocate bounded energy to 18:00–20:00.',
},
},
router_decision: {
'day-ahead-bid': {
workflow_id: 'day-ahead-bid',
params: { market_date: DATE },
confidence: 'HIGH',
},
},
bid_export_file: {
basic: {
format_version: '1.0.0',
proposal_id: 'prop-001',
proposal_digest: REF_B,
permit_id: 'permit-001',
proposal_type: 'BID',
market_date: DATE,
prices_yuan_per_mwh: curve('0.00'),
quantities_mwh: curve('12.5'),
exported_at: T1,
},
},
situation_report: { situation_report: {
'normal-day': { 'normal-day': {
id: 'sit-001', id: 'sit-001',
@ -333,6 +428,20 @@ const invalid: Record<string, Record<string, unknown>> = {
return o return o
})(), })(),
}, },
bid_proposal_draft: {
// a literal number where only a lineage reference is allowed (I1/P2)
'literal-number': {
market_date: DATE,
prices_ref: { tool_call_id: 'tc-001', path: 'prices_yuan_per_mwh' },
quantities_ref: { tool_call_id: 'tc-001', path: 'quantities_mwh' },
expected_revenue_ref: '510600.00',
rationale: 'x',
},
},
router_decision: {
// a workflow id that is not a registered template
'unknown-template': { workflow_id: 'transfer-funds', params: { market_date: DATE }, confidence: 'HIGH' },
},
position_update: { position_update: {
// missing optimistic-concurrency field // missing optimistic-concurrency field
'missing-version': (() => { 'missing-version': (() => {

View File

@ -3,6 +3,7 @@ import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js'
import { ProposalType } from './proposal.js' import { ProposalType } from './proposal.js'
export const ApprovalLevel = z.enum(['L1', 'L2', 'L3']) export const ApprovalLevel = z.enum(['L1', 'L2', 'L3'])
export type ApprovalLevel = z.infer<typeof ApprovalLevel>
/** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */ /** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */
export const Approval = z.object({ export const Approval = z.object({

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { Id, IsoUtc } from './common.js' import { Id, IsoUtc } from './common.js'
export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS']) export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS'])
export type CaseKind = z.infer<typeof CaseKind>
/** /**
* Operator's unit of accountability (docs/02 §5, ADR-0008): one objective, * Operator's unit of accountability (docs/02 §5, ADR-0008): one objective,

View File

@ -0,0 +1,119 @@
import { z } from 'zod'
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
import { ApprovalLevel } from './approval.js'
import { ProposalType } from './proposal.js'
/**
* Safety-chain objects (docs/03, docs/11 §2): the outputs of rule check,
* simulation, envelope gate and gateway. All are snapshotted into lineage so a
* decision can be replayed (I7).
*/
export const PolicyPackRef = z.object({
id: Id,
version: z.string().min(1),
})
export type PolicyPackRef = z.infer<typeof PolicyPackRef>
export const RuleViolation = z.object({
rule_id: Id,
severity: z.enum(['REJECT', 'WARN']),
message: z.string().min(1),
})
export const ValidationResult = z.object({
proposal_digest: SnapshotRef,
policy_pack: PolicyPackRef,
ok: z.boolean(),
violations: z.array(RuleViolation),
rules_evaluated: z.array(Id),
checked_at: IsoUtc,
})
export type ValidationResult = z.infer<typeof ValidationResult>
/** Revenue scenario backtest (bids) or power-balance sim (control) — docs/03 §2. */
export const SimulationResult = z.object({
proposal_digest: SnapshotRef,
kind: z.enum(['REVENUE_SCENARIOS', 'POWER_BALANCE']),
/** Any alert escalates to a human — one vote, no exceptions (docs/03 §2). */
alert: z.boolean(),
alerts: z.array(z.string()),
metrics: z.record(z.string(), DecimalString),
scenario_count: z.int().nonnegative(),
simulator: z.object({ name: z.string().min(1), version: z.string().min(1), seed: z.int() }),
simulated_at: IsoUtc,
})
export type SimulationResult = z.infer<typeof SimulationResult>
export const EnvelopeMatch = z.object({
proposal_digest: SnapshotRef,
within: z.boolean(),
envelope_id: Id.nullable(),
required_level: ApprovalLevel,
reasons: z.array(z.string()),
checked_at: IsoUtc,
})
export type EnvelopeMatch = z.infer<typeof EnvelopeMatch>
export const StaleDenial = z.object({
proposal_digest: SnapshotRef,
reasons: z.array(z.string().min(1)).min(1),
checked_at: IsoUtc,
})
export type StaleDenial = z.infer<typeof StaleDenial>
/** Idempotent effect receipt (I5). Same (digest, permit) → same receipt. */
export const ExecutionReceipt = z.object({
receipt_id: Id,
proposal_digest: SnapshotRef,
permit_id: Id,
channel: z.enum(['FILE_EXPORT', 'TRADING_PLATFORM_API', 'SIMULATION_GATEWAY']),
idempotency_key: z.string().min(1),
artifact_ref: z.string().min(1),
accepted_at: IsoUtc,
})
export type ExecutionReceipt = z.infer<typeof ExecutionReceipt>
/** Reference into recorded tool-call lineage (I1/P2): the only way a number enters a payload. */
export const LineageRef = z.object({
tool_call_id: Id,
path: z.string().min(1),
})
export type LineageRef = z.infer<typeof LineageRef>
/**
* What the trading agent's LLM step is allowed to emit (docs/09 §3): refs and
* prose, never numbers. The assembler dereferences refs into a BidPayload.
*/
export const BidProposalDraft = z.object({
market_date: MarketDate,
prices_ref: LineageRef,
quantities_ref: LineageRef,
expected_revenue_ref: LineageRef,
rationale: z.string().min(1),
})
export type BidProposalDraft = z.infer<typeof BidProposalDraft>
/** Router agent output (docs/09 §1): choose a registered template, never invent one. */
export const WorkflowTemplateId = z.enum(['day-ahead-situation', 'day-ahead-bid', 'adhoc-analysis'])
export const RouterDecision = z.object({
workflow_id: WorkflowTemplateId,
params: z.object({ market_date: MarketDate }),
confidence: z.enum(['HIGH', 'MEDIUM', 'LOW']),
})
export type RouterDecision = z.infer<typeof RouterDecision>
/** Bid file handed to the manual-upload channel (docs/06 degraded channel; M3 release path). */
export const BidExportFile = z.object({
format_version: z.literal('1.0.0'),
proposal_id: Id,
proposal_digest: SnapshotRef,
permit_id: Id,
proposal_type: ProposalType,
market_date: MarketDate,
prices_yuan_per_mwh: Curve96,
quantities_mwh: Curve96,
exported_at: IsoUtc,
})
export type BidExportFile = z.infer<typeof BidExportFile>

View File

@ -15,6 +15,16 @@ import {
ReportRequest, ReportRequest,
SkillReport, SkillReport,
} from './skill.js' } from './skill.js'
import {
BidExportFile,
BidProposalDraft,
EnvelopeMatch,
ExecutionReceipt,
RouterDecision,
SimulationResult,
StaleDenial,
ValidationResult,
} from './chain.js'
export * from './common.js' export * from './common.js'
export * from './proposal.js' export * from './proposal.js'
@ -27,6 +37,7 @@ export * from './resource.js'
export * from './case.js' export * from './case.js'
export * from './event.js' export * from './event.js'
export * from './skill.js' export * from './skill.js'
export * from './chain.js'
/** /**
* Registry driving the contracts pipeline: keys become schema/fixture/module * Registry driving the contracts pipeline: keys become schema/fixture/module
@ -50,4 +61,12 @@ export const schemaRegistry: Record<string, z.ZodType> = {
bid_optimization_result: BidOptimizationResult, bid_optimization_result: BidOptimizationResult,
report_request: ReportRequest, report_request: ReportRequest,
skill_report: SkillReport, skill_report: SkillReport,
validation_result: ValidationResult,
simulation_result: SimulationResult,
envelope_match: EnvelopeMatch,
stale_denial: StaleDenial,
execution_receipt: ExecutionReceipt,
bid_proposal_draft: BidProposalDraft,
router_decision: RouterDecision,
bid_export_file: BidExportFile,
} }

View File

@ -24,6 +24,7 @@ export const ProposalStatus = z.enum([
'ROLLED_BACK', 'ROLLED_BACK',
'REJECTED', 'REJECTED',
]) ])
export type ProposalStatus = z.infer<typeof ProposalStatus>
export const ToolCallRef = z.object({ export const ToolCallRef = z.object({
tool_call_id: Id, tool_call_id: Id,
@ -32,6 +33,7 @@ export const ToolCallRef = z.object({
inputs_ref: SnapshotRef, inputs_ref: SnapshotRef,
outputs_ref: SnapshotRef, outputs_ref: SnapshotRef,
}) })
export type ToolCallRef = z.infer<typeof ToolCallRef>
/** /**
* Provenance attached to every proposal (docs/03 §1). Rule check enforces that * Provenance attached to every proposal (docs/03 §1). Rule check enforces that
@ -43,6 +45,7 @@ export const Lineage = z.object({
ledger_version: z.int().nonnegative(), ledger_version: z.int().nonnegative(),
policy_pack_version: z.string().min(1), policy_pack_version: z.string().min(1),
}) })
export type Lineage = z.infer<typeof Lineage>
/** Day-ahead bid: per-interval price/quantity pairs. */ /** Day-ahead bid: per-interval price/quantity pairs. */
export const BidPayload = z.object({ export const BidPayload = z.object({
@ -52,6 +55,7 @@ export const BidPayload = z.object({
quantities_mwh: Curve96, quantities_mwh: Curve96,
expected_revenue_yuan: DecimalString, expected_revenue_yuan: DecimalString,
}) })
export type BidPayload = z.infer<typeof BidPayload>
const ProposalBase = z.object({ const ProposalBase = z.object({
id: Id, id: Id,

View File

@ -9,7 +9,7 @@
*/ */
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { HttpSkillClient } from './client.js' import { HttpSkillClient } from '@vpp/services'
import { loadDataset } from './dataset.js' import { loadDataset } from './dataset.js'
import { DEFAULT_CONFIG, runL2, stableJson } from './harness.js' import { DEFAULT_CONFIG, runL2, stableJson } from './harness.js'
import type { EvalRun } from './harness.js' import type { EvalRun } from './harness.js'

View File

@ -1,7 +1,7 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import type { BidOptimizationResult, BidRiskParams, ForecastBundle, ForecastKind } from '@vpp/domain' import type { BidOptimizationResult, BidRiskParams, ForecastBundle, ForecastKind } from '@vpp/domain'
import { Decimal, LedgerService } from '@vpp/services' import { Decimal, LedgerService } from '@vpp/services'
import type { SkillClient } from './client.js' import type { SkillClient } from '@vpp/services'
import type { Dataset, DatasetDay } from './dataset.js' import type { Dataset, DatasetDay } from './dataset.js'
import { nums, toCurve } from './dataset.js' import { nums, toCurve } from './dataset.js'
import { import {

View File

@ -1,4 +1,5 @@
export * from './metrics.js' export * from './metrics.js'
export * from './dataset.js' export * from './dataset.js'
export * from './client.js' export { HttpSkillClient, SkillHttpError } from '@vpp/services'
export type { SkillClient } from '@vpp/services'
export * from './harness.js' export * from './harness.js'

View File

@ -9,7 +9,7 @@ import type {
ReportRequest, ReportRequest,
SkillReport, SkillReport,
} from '@vpp/domain' } from '@vpp/domain'
import type { SkillClient } from '../src/client.js' import type { SkillClient } from '@vpp/services'
import { loadDataset } from '../src/dataset.js' import { loadDataset } from '../src/dataset.js'
import { DEFAULT_CONFIG, runL2 } from '../src/harness.js' import { DEFAULT_CONFIG, runL2 } from '../src/harness.js'

View File

@ -0,0 +1,27 @@
{
"name": "@vpp/runtime",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run",
"start": "tsx src/main.ts"
},
"dependencies": {
"@mastra/core": "^1.63.2",
"@mastra/libsql": "^1.22.2",
"@vpp/domain": "*",
"@vpp/services": "*",
"zod": "^4.1.0"
},
"devDependencies": {
"@types/node": "^26.4.1",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,68 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { z } from 'zod'
import { LlmUnavailable } from './llm.js'
import type { Runtime } from './runtime.js'
import type { TriggerService } from './trigger.js'
import { ResumeDecision } from './workflows/proposal-lifecycle.js'
/**
* Case Desk v1 HTTP API (docs/02 §5, docs/11 §1.1 CaseDeskPort). Inbox, case
* view, lineage expansion, and the approve/reject endpoint that resumes the
* lifecycle run. Identity comes from the existing permission system
* (docs/06 §4) — here as trusted headers behind that system's gateway:
* x-user-id / x-user-role. The approver identity is what I1/I2 checks.
*/
const DecideBody = z.object({ decision: z.enum(['approve', 'reject']), comment: z.string().optional() })
const TaskBody = z.object({ message: z.string().min(1) })
const json = (res: ServerResponse, status: number, body: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
const readBody = (req: IncomingMessage): Promise<unknown> =>
new Promise((resolve, reject) => {
let data = ''
req.on('data', (c) => (data += c))
req.on('end', () => {
try {
resolve(data ? JSON.parse(data) : {})
} catch (e) {
reject(e)
}
})
})
export function createApi(rt: Runtime, triggers: TriggerService): Server {
return createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost')
const parts = url.pathname.split('/').filter(Boolean)
const user = { id: req.headers['x-user-id'], role: req.headers['x-user-role'] }
try {
if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { status: 'ok', llm: rt.ctx.llm.backend })
if (req.method === 'GET' && url.pathname === '/inbox') return json(res, 200, rt.ctx.caseDesk.inbox().filter((p) => p.run_id !== ''))
if (req.method === 'GET' && url.pathname === '/cases') return json(res, 200, rt.ctx.caseDesk.cases.list().map((r) => r.value))
if (req.method === 'GET' && parts[0] === 'cases' && parts[1] && parts.length === 2) return json(res, 200, rt.ctx.caseDesk.read(parts[1]))
if (req.method === 'GET' && parts[0] === 'proposals' && parts[1] && parts[2] === 'lineage') return json(res, 200, rt.ctx.caseDesk.expandLineage(parts[1]))
if (req.method === 'GET' && url.pathname === '/events') return json(res, 200, rt.ctx.events.list(url.searchParams.get('correlation_id') ? { correlation_id: url.searchParams.get('correlation_id')! } : {}))
if (req.method === 'POST' && parts[0] === 'approvals' && parts[1] && parts[2] === 'decide') {
if (typeof user.id !== 'string' || typeof user.role !== 'string') return json(res, 401, { error: 'x-user-id and x-user-role required' })
const body = DecideBody.parse(await readBody(req))
const decision = ResumeDecision.parse({ ...body, approver: { id: user.id, role: user.role } })
const result = await triggers.decide(parts[1], decision)
return json(res, 200, { run_id: parts[1], status: result.status, outcome: result.status === 'success' ? result.result.outcome : null })
}
if (req.method === 'POST' && url.pathname === '/tasks') {
const body = TaskBody.parse(await readBody(req))
return json(res, 200, await triggers.manual(body.message))
}
return json(res, 404, { error: 'not found' })
} catch (e) {
if (e instanceof z.ZodError) return json(res, 400, { error: e.issues })
if (e instanceof LlmUnavailable) return json(res, 503, { error: e.message })
return json(res, 500, { error: (e as Error).message })
}
})
}

View File

@ -0,0 +1,61 @@
import type {
AuthorityService,
CaseDeskService,
EnvelopeService,
EventBus,
FileExportGateway,
LedgerService,
LineageRecorder,
PolicyEngine,
ResourceRegistry,
SimulationPort,
SkillClient,
SnapshotStore,
TimeSeriesStore,
} from '@vpp/services'
import type { LlmPort } from './llm.js'
/**
* Everything a workflow step may touch. Deterministic services are plain
* objects imported by steps (docs/09 §7: never exposed to the LLM); the LLM
* port is the one non-deterministic dependency and is isolated behind
* `llm`. Workflows are created per runtime and close over their context, so
* two runtimes can coexist in one process — which is what the restart test does.
*/
export interface RuntimeConfig {
policyPackId: string
/** OPEN-QUESTION B3: which roles may approve; placeholder list. */
approverRoles: string[]
/** OPEN-QUESTION A1: bid permit lifetime — until the market deadline. Placeholder. */
bidPermitTtlMs: number
/** OPEN-QUESTION B6 / A6 / A4 placeholders passed to the optimizer. */
risk: { risk_aversion: string; commitment_buffer_k: string; min_block_mwh: string; marginal_cost_yuan_per_mwh: string }
/** OPEN-QUESTION B5: worst-case loss budget for the simulation alert. Placeholder. */
worstCaseLossBudgetYuan: string
/** History window (days) handed to forecast skills. */
forecastWindowDays: number
/** OPEN-QUESTION B7: price-spike ratio (p90/p50) above which the day is EXTREME. Placeholder. */
extremeDayPriceRatio: string
/** Bid deadline as HH:MM Asia/Shanghai on D-1 (OPEN-QUESTION A1); used for case deadlines. */
bidDeadlineLocal: string
}
export interface RuntimeContext {
config: RuntimeConfig
clock: () => string
newId: (prefix: string) => string
snapshots: SnapshotStore
timeseries: TimeSeriesStore
resources: ResourceRegistry
ledger: LedgerService
events: EventBus
policy: PolicyEngine
envelopes: EnvelopeService
authority: AuthorityService
gateway: FileExportGateway
simulation: SimulationPort
caseDesk: CaseDeskService
skills: SkillClient
lineage: LineageRecorder
llm: LlmPort
}

View File

@ -0,0 +1,9 @@
export * from './llm.js'
export * from './skills.js'
export * from './context.js'
export * from './runtime.js'
export * from './trigger.js'
export * from './api.js'
export * from './workflows/proposal-lifecycle.js'
export * from './workflows/day-ahead-situation.js'
export * from './workflows/day-ahead-bid.js'

109
packages/runtime/src/llm.ts Normal file
View File

@ -0,0 +1,109 @@
import { Agent } from '@mastra/core/agent'
import type { MastraModelConfig } from '@mastra/core/llm'
import type { z } from 'zod'
/**
* LLM provider abstraction (P6, ADR-0003). Agent code programs against
* `LlmPort.structured()` — "give me an object of this schema" — never a vendor
* API. Backends: a Mastra Agent over a router model id (dev: commercial API;
* prod: the GuangMing OpenAI-compatible adapter), a scripted provider for
* tests, and the null provider that models an LLM outage. Every caller must
* survive `LlmUnavailable` (I6) — the deterministic fallback is part of the
* step, not an afterthought.
*/
export class LlmUnavailable extends Error {
constructor(reason: string) {
super(`LLM unavailable: ${reason}`)
this.name = 'LlmUnavailable'
}
}
export interface LlmPort {
readonly backend: string
structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T>
}
export interface AgentSpec {
id: string
name: string
instructions: string
}
/** Real backend: one Mastra Agent per spec, all on the same routed model. */
export class MastraLlm implements LlmPort {
readonly backend: string
private readonly agents = new Map<string, Agent>()
constructor(model: MastraModelConfig, specs: AgentSpec[], backend = 'mastra') {
this.backend = backend
for (const s of specs) this.agents.set(s.id, new Agent({ id: s.id, name: s.name, instructions: s.instructions, model }))
}
mastraAgents(): Record<string, Agent> {
return Object.fromEntries(this.agents)
}
async structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T> {
const agent = this.agents.get(agentId)
if (!agent) throw new Error(`agent ${agentId} not registered`)
let res: { object?: unknown }
try {
res = await agent.generate(prompt, { structuredOutput: { schema: schema as never } })
} catch (e) {
throw new LlmUnavailable((e as Error).message)
}
const parsed = schema.safeParse(res.object)
if (!parsed.success) throw new LlmUnavailable(`structured output failed schema: ${parsed.error.message}`)
return parsed.data
}
}
/** LLM outage (or simply "no model configured"): every call fails fast. */
export class NullLlm implements LlmPort {
readonly backend = 'null'
async structured<T>(): Promise<T> {
throw new LlmUnavailable('no LLM backend configured')
}
}
/** Test backend: canned answers keyed by agent id, optionally a function of the prompt. */
export class ScriptedLlm implements LlmPort {
readonly backend = 'scripted'
readonly prompts: Array<{ agentId: string; prompt: string }> = []
constructor(private readonly answers: Record<string, unknown | ((prompt: string) => unknown)>) {}
async structured<T>(agentId: string, prompt: string, schema: z.ZodType<T>): Promise<T> {
this.prompts.push({ agentId, prompt })
const a = this.answers[agentId]
if (a === undefined) throw new LlmUnavailable(`no scripted answer for ${agentId}`)
const raw = typeof a === 'function' ? (a as (p: string) => unknown)(prompt) : a
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new LlmUnavailable(`scripted output failed schema: ${parsed.error.message}`)
return parsed.data
}
}
/** The five agents (docs/02 §2). M3 wires three; interaction/load-control are phase 2. */
export const AGENT_SPECS: AgentSpec[] = [
{
id: 'router-agent',
name: 'Router',
instructions:
'You route an operator request to exactly one registered workflow template and extract its parameters. ' +
'You never invent workflows. If the request is ambiguous, choose adhoc-analysis with LOW confidence.',
},
{
id: 'analysis-agent',
name: 'Analysis',
instructions:
'You are the situational-awareness analyst for a virtual power plant. Given forecast bundles (already computed by tools), ' +
'write short findings. Never state a number that is not in the provided tool outputs; cite snapshot refs.',
},
{
id: 'trading-agent',
name: 'Trading',
instructions:
'You draft day-ahead bid proposals. All numbers come from the bid-optimization tool output you are given; ' +
'you reference them by {tool_call_id, path} and explain the strategy in one paragraph for the approver.',
},
]

View File

@ -0,0 +1,24 @@
/**
* Dev/shadow entry point: `npm run start -w @vpp/runtime`.
* VPP_DATA_DIR durable state (default ./data)
* VPP_SKILLS_URL Python skill service (default http://127.0.0.1:8000)
* VPP_LLM_MODEL Mastra router model id, e.g. openai/gpt-... ; unset = LLM down (I6 mode)
* VPP_API_PORT Case Desk API port (default 4100)
* Scheduled triggers (D-1 06:00 situation, 08:00 bid, Asia/Shanghai) run in-process.
*/
import { createApi } from './api.js'
import { createRuntime } from './runtime.js'
import { TriggerService } from './trigger.js'
const rt = await createRuntime({
dataDir: process.env['VPP_DATA_DIR'] ?? './data',
skills: process.env['VPP_SKILLS_URL'] ?? 'http://127.0.0.1:8000',
llm: process.env['VPP_LLM_MODEL'] ? (process.env['VPP_LLM_MODEL'] as `${string}/${string}`) : null,
})
const triggers = new TriggerService(rt)
triggers.startEventConsumers()
triggers.startScheduler()
const port = Number(process.env['VPP_API_PORT'] ?? 4100)
createApi(rt, triggers).listen(port, () => {
console.log(`vpp runtime: api on :${port}, llm=${rt.ctx.llm.backend}, data=${process.env['VPP_DATA_DIR'] ?? './data'}`)
})

View File

@ -0,0 +1,133 @@
import { join } from 'node:path'
import { Mastra } from '@mastra/core'
import type { MastraModelConfig } from '@mastra/core/llm'
import { LibSQLStore } from '@mastra/libsql'
import { Approval, DecisionCase, Envelope, ExecutionPermit, ExecutionReceipt, Proposal, ResourceProfile } from '@vpp/domain'
import {
AuthorityService,
CaseDeskService,
EnvelopeService,
FileEventBus,
FileExportGateway,
FsRepository,
FsSnapshotStore,
HUBEI_BID_PACK_PLACEHOLDER,
HttpSkillClient,
LedgerService,
LineageRecorder,
MemoryTimeSeriesStore,
PendingApprovalSchema,
PolicyEngine,
RevenueScenarioSimulator,
hubeiSpotBiddingPack,
} from '@vpp/services'
import type { SkillClient } from '@vpp/services'
import type { RuntimeConfig, RuntimeContext } from './context.js'
import { AGENT_SPECS, MastraLlm, NullLlm } from './llm.js'
import type { LlmPort } from './llm.js'
import { createDayAheadBid } from './workflows/day-ahead-bid.js'
import { createDayAheadSituation } from './workflows/day-ahead-situation.js'
import { createProposalLifecycle } from './workflows/proposal-lifecycle.js'
/**
* Runtime = thin shell over Mastra + the self-built services (docs/09 §0).
* `createRuntime` wires storage, workflows and agents; everything durable
* lives under `dataDir` so a second `createRuntime` on the same directory
* *is* a process restart for the purposes of the resumability tests.
*/
export interface RuntimeOptions {
dataDir: string
/** Router model id (e.g. 'openai/gpt-...'), an OpenAI-compatible config, or an LlmPort for tests; null = LLM down. */
llm: MastraModelConfig | LlmPort | null
skills: SkillClient | string
clock?: () => string
config?: Partial<RuntimeConfig>
/** Optional in-memory overrides for tests (time series is in-memory in phase 1 regardless). */
timeseries?: RuntimeContext['timeseries']
}
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
policyPackId: 'hubei-spot-bidding',
approverRoles: ['senior-trader', 'ops-lead', 'risk-officer'], // OPEN-QUESTION B3
bidPermitTtlMs: 6 * 3_600_000, // OPEN-QUESTION A1
risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '0' }, // B6 / A6 / A4
worstCaseLossBudgetYuan: '100000', // OPEN-QUESTION B5
forecastWindowDays: 28,
extremeDayPriceRatio: '2.0', // OPEN-QUESTION B7
bidDeadlineLocal: '09:00', // OPEN-QUESTION A1
}
export interface Runtime {
mastra: Mastra
ctx: RuntimeContext
close(): Promise<void>
}
const isLlmPort = (x: unknown): x is LlmPort => !!x && typeof x === 'object' && 'structured' in (x as object) && 'backend' in (x as object)
export async function createRuntime(opts: RuntimeOptions): Promise<Runtime> {
const config = { ...DEFAULT_RUNTIME_CONFIG, ...opts.config }
const clock = opts.clock ?? (() => new Date().toISOString())
let seq = 0
const newId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${(++seq).toString(36)}-${Math.random().toString(36).slice(2, 6)}`
const dir = (name: string) => join(opts.dataDir, name)
const snapshots = new FsSnapshotStore(dir('snapshots'))
const events = new FileEventBus(dir('events.jsonl'), clock)
const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock }) // OPEN-QUESTION A5
const authority = new AuthorityService(new FsRepository(dir('permits.json'), ExecutionPermit, (p) => p.id, clock), () => newId('permit'))
const policy = new PolicyEngine([hubeiSpotBiddingPack('2026.03', HUBEI_BID_PACK_PLACEHOLDER)])
const envelopes = new EnvelopeService(new FsRepository(dir('envelopes.json'), Envelope, (e) => e.id, clock))
const caseDesk = new CaseDeskService({
cases: new FsRepository(dir('cases.json'), DecisionCase, (c) => c.id, clock),
proposals: new FsRepository(dir('proposals.json'), Proposal, (p) => p.id, clock),
approvals: new FsRepository(dir('approvals.json'), Approval, (a) => a.id, clock),
pending: new FsRepository(dir('pending.json'), PendingApprovalSchema, (p) => p.proposal_id, clock),
authority,
snapshots,
events,
clock,
})
const gateway = new FileExportGateway(authority, dir('exports'), new FsRepository(dir('receipts.json'), ExecutionReceipt, (r) => r.idempotency_key, clock))
let llm: LlmPort
let agents: Record<string, never> | ReturnType<MastraLlm['mastraAgents']> = {}
if (opts.llm === null) llm = new NullLlm()
else if (isLlmPort(opts.llm)) llm = opts.llm
else {
const m = new MastraLlm(opts.llm, AGENT_SPECS)
llm = m
agents = m.mastraAgents()
}
const ctx: RuntimeContext = {
config,
clock,
newId,
snapshots,
timeseries: opts.timeseries ?? new MemoryTimeSeriesStore(clock),
resources: new FsRepository(dir('resources.json'), ResourceProfile, (r) => r.resource_id, clock),
ledger,
events,
policy,
envelopes,
authority,
gateway,
simulation: new RevenueScenarioSimulator(),
caseDesk,
skills: typeof opts.skills === 'string' ? new HttpSkillClient(opts.skills) : opts.skills,
lineage: new LineageRecorder(),
llm,
}
const storage = new LibSQLStore({ id: 'vpp-runtime', url: `file:${dir('mastra.db')}` })
// Workflows are built per runtime so their steps close over *this* context;
// module-level singletons would bind to whichever Mastra registered last.
const proposalLifecycle = createProposalLifecycle(ctx)
const mastra = new Mastra({
storage,
workflows: { proposalLifecycle, dayAheadSituation: createDayAheadSituation(ctx), dayAheadBid: createDayAheadBid(ctx, proposalLifecycle) },
agents,
})
return { mastra, ctx, close: () => storage.close() }
}

View File

@ -0,0 +1,33 @@
import type { ToolCallRef } from '@vpp/domain'
import type { LineageRecorder, SnapshotStore } from '@vpp/services'
/**
* Skill registry wrapper (docs/09 §4): every skill call snapshots its input
* and output and records a ToolCallRef into the run's lineage buffer. The
* Mastra Tool surface (for agent-initiated calls) is layered on top in a
* later milestone; M3 workflows call skills deterministically from steps,
* which is the docs/09 §3 discipline ("context assembled by upstream steps").
*/
export interface RegisteredSkill<I, O> {
id: string
version: string
call(input: I): Promise<{ output: O; ref: ToolCallRef }>
}
export function registerSkill<I, O>(
deps: { lineage: LineageRecorder; snapshots: SnapshotStore; idGen: () => string },
def: { id: string; version: string; invoke: (input: I) => Promise<O> },
): RegisteredSkill<I, O> {
return {
id: def.id,
version: def.version,
async call(input: I) {
const inputs_ref = deps.snapshots.put(input)
const output = await def.invoke(input)
const outputs_ref = deps.snapshots.put(output)
const ref: ToolCallRef = { tool_call_id: deps.idGen(), tool: def.id, version: def.version, inputs_ref, outputs_ref }
deps.lineage.record(ref)
return { output, ref }
},
}
}

View File

@ -0,0 +1,133 @@
import { RouterDecision } from '@vpp/domain'
import type { EventEnvelope } from '@vpp/domain'
import { LlmUnavailable } from './llm.js'
import type { Runtime } from './runtime.js'
import { ENVELOPE_GATE_STEP_ID } from './workflows/proposal-lifecycle.js'
import type { ResumeDecision } from './workflows/proposal-lifecycle.js'
/**
* Trigger service (docs/02 §1, docs/09 §1): three sources, one shape —
* "instantiate template X with params Y".
* scheduled: cron entries (D-1 06:00 situation, 08:00 bid) — no LLM in the path
* event: bus consumers with deterministic rules
* manual: the router agent turns free text into a RouterDecision
* (a template id + params, never a new flow); on LLM outage the
* operator picks the template explicitly.
* Approval is a resume on the lifecycle run (docs/09 §2).
*/
export interface ScheduleEntry {
id: string
/** 'HH:MM' Asia/Shanghai on D-1 */
localTime: string
workflow: 'dayAheadSituation' | 'dayAheadBid'
}
export const DEFAULT_SCHEDULE: ScheduleEntry[] = [
{ id: 'situation-0600', localTime: '06:00', workflow: 'dayAheadSituation' }, // docs/07 D-1 06:00
{ id: 'bid-0800', localTime: '08:00', workflow: 'dayAheadBid' }, // docs/07 D-1 08:00; window is OPEN-QUESTION A1
]
export const nextMarketDate = (nowIso: string): string => {
const shanghai = new Date(new Date(nowIso).getTime() + 8 * 3_600_000)
shanghai.setUTCDate(shanghai.getUTCDate() + 1)
return shanghai.toISOString().slice(0, 10)
}
export class TriggerService {
private timer: NodeJS.Timeout | null = null
private readonly firedToday = new Set<string>()
private readonly unsubscribe: Array<() => void> = []
constructor(
private readonly rt: Runtime,
private readonly schedule: ScheduleEntry[] = DEFAULT_SCHEDULE,
) {}
// ---- scheduled ---------------------------------------------------------
/** Polls the clock; fires each entry once per local day. Deterministic, LLM-free. */
startScheduler(pollMs = 30_000): void {
if (this.timer) return
this.timer = setInterval(() => void this.tick(), pollMs)
this.timer.unref()
}
stop(): void {
if (this.timer) clearInterval(this.timer)
this.timer = null
for (const u of this.unsubscribe) u()
}
async tick(nowIso = this.rt.ctx.clock()): Promise<string[]> {
const local = new Date(new Date(nowIso).getTime() + 8 * 3_600_000)
const hhmm = local.toISOString().slice(11, 16)
const day = local.toISOString().slice(0, 10)
const fired: string[] = []
for (const entry of this.schedule) {
const key = `${day}:${entry.id}`
if (hhmm >= entry.localTime && !this.firedToday.has(key)) {
this.firedToday.add(key)
await this.startTemplate(entry.workflow, { market_date: nextMarketDate(nowIso) }, 'SCHEDULED', entry.id)
fired.push(entry.id)
}
}
return fired
}
// ---- event -------------------------------------------------------------
/** Deterministic event rules. M3: a SituationPublished with EXTREME risk opens an abnormal-day case (docs/13 §1). */
startEventConsumers(): void {
this.unsubscribe.push(
this.rt.ctx.events.subscribe('SituationPublished', (evt: EventEnvelope) => {
const payload = evt.payload as { report: { market_date: string; risk_level: string; id: string } }
if (payload.report.risk_level === 'EXTREME') {
this.rt.ctx.caseDesk.open({
id: this.rt.ctx.newId('case'),
kind: 'ADHOC_ANALYSIS',
objective: `Abnormal-day protocol review for ${payload.report.market_date} (situation ${payload.report.id})`,
owner: 'user-ops-lead', // OPEN-QUESTION B8
deadline: `${payload.report.market_date}T00:00:00Z`,
})
}
}),
)
}
// ---- manual ------------------------------------------------------------
async route(message: string): Promise<RouterDecision> {
const prompt = `Operator request: "${message}". Today (UTC) is ${this.rt.ctx.clock()}. Default market_date is ${nextMarketDate(this.rt.ctx.clock())}.`
try {
return await this.rt.ctx.llm.structured('router-agent', prompt, RouterDecision)
} catch (e) {
if (e instanceof LlmUnavailable) throw new LlmUnavailable('router needs an LLM; pick a template explicitly via startTemplate')
throw e
}
}
async manual(message: string): Promise<{ decision: RouterDecision; run_id: string | null }> {
const decision = await this.route(message)
if (decision.workflow_id === 'adhoc-analysis') return { decision, run_id: null }
const run_id = await this.startTemplate(decision.workflow_id === 'day-ahead-bid' ? 'dayAheadBid' : 'dayAheadSituation', decision.params, 'MANUAL', 'router')
return { decision, run_id }
}
async startTemplate(workflow: 'dayAheadSituation' | 'dayAheadBid', params: { market_date: string }, trigger: 'SCHEDULED' | 'EVENT' | 'MANUAL', source: string): Promise<string> {
const wf = this.rt.mastra.getWorkflow(workflow)
const run = await wf.createRun()
this.rt.ctx.events.append({ event_type: 'WorkflowTriggered', payload: { workflow, params, trigger, source, run_id: run.runId }, correlation_id: run.runId })
const result = await run.start({ inputData: params })
this.rt.ctx.events.append({ event_type: 'WorkflowFinished', payload: { workflow, run_id: run.runId, status: result.status }, correlation_id: run.runId })
if (result.status === 'failed') throw result.error
return run.runId
}
// ---- approval = resume -------------------------------------------------
async decide(runId: string, decision: ResumeDecision) {
const wf = this.rt.mastra.getWorkflow('proposalLifecycle')
const run = await wf.createRun({ runId })
return run.resume({ step: ENVELOPE_GATE_STEP_ID, resumeData: decision })
}
}

View File

@ -0,0 +1,167 @@
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'
import type { BidProposalDraft, Proposal, ToolCallRef } from '@vpp/domain'
import { BidProposalDraft as BidProposalDraftSchema, Curve96, MarketDate, Proposal as ProposalSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
import { Decimal, assembleBidProposal } from '@vpp/services'
import type { RuntimeContext } from '../context.js'
import { LlmUnavailable } from '../llm.js'
import { registerSkill } from '../skills.js'
import { runForecasts } from './day-ahead-situation.js'
import { LifecycleOutput } from './proposal-lifecycle.js'
import type { createProposalLifecycle } from './proposal-lifecycle.js'
/**
* D-1 08:00 申报策略与报价优化 (docs/07, docs/09 §3): deterministic context
* assembly (ledger bounds, capacity, price forecast) → MILP tool → the
* trading agent drafts a proposal made of *references* → assembler builds
* the Proposal from lineage → submit to the lifecycle workflow.
* With the LLM down the draft is templated; the numbers are identical.
*/
export const BidInput = z.object({ market_date: MarketDate, case_id: z.string().min(1).optional() })
export const BidOutput = z.object({
case_id: z.string(),
proposal_id: z.string(),
proposal: ProposalSchema,
lifecycle_run_id: z.string(),
lifecycle: LifecycleOutput.nullable(),
lifecycle_status: z.enum(['success', 'suspended', 'failed']),
llm_used: z.boolean(),
})
const Context = z.object({
market_date: MarketDate,
case_id: z.string(),
tool_calls: z.array(ToolCallRefSchema),
data_refs: z.array(z.string()),
milp_call: ToolCallRefSchema,
milp_output: z.unknown(),
ledger_version: z.int(),
policy_pack_version: z.string(),
capacity: Curve96,
})
function capacityCurve(ctx: RuntimeContext, marketDate: string) {
const total = ctx.resources.list().reduce((s, r) => s.add(new Decimal(r.value.certified_adjustable_mw)), new Decimal(0))
return { interval_minutes: 15 as const, date: marketDate, values: Array.from({ length: 96 }, () => total.toFixed(3)) }
}
export function createDayAheadBid(ctx: RuntimeContext, lifecycle: ReturnType<typeof createProposalLifecycle>) {
const fetchContext = createStep({
id: 'fetch-context',
inputSchema: BidInput,
outputSchema: Context,
execute: async ({ inputData }) => {
const caseId =
inputData.case_id ??
ctx.caseDesk.open({
id: ctx.newId('case'),
kind: 'DAY_AHEAD_BID',
objective: `Submit day-ahead bid for ${inputData.market_date}`,
owner: 'user-trader-01', // OPEN-QUESTION B3: owner role mapping
// D-1 at the local bid deadline (Asia/Shanghai = UTC+8), stored as UTC.
deadline: new Date(new Date(`${inputData.market_date}T${ctx.config.bidDeadlineLocal}:00Z`).getTime() - 86_400_000 - 8 * 3_600_000).toISOString(),
}).id
return ctx.lineage.run(async () => {
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
const bounds = ctx.ledger.dayAheadBounds(inputData.market_date)
const capacity = capacityCurve(ctx, inputData.market_date)
ctx.lineage.addDataRef(ctx.snapshots.put({ resources: ctx.resources.list().map((r) => ({ id: r.key, version: r.version })) }))
const milp = registerSkill(
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
{ id: 'bid-optimization-milp', version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.optimizeBid>[0]) => ctx.skills.optimizeBid(req) },
)
const { output, ref } = await milp.call({ market_date: inputData.market_date, price_forecast: forecasts.PRICE, adjustable_capacity_mw: capacity, position_bounds: bounds, risk: ctx.config.risk })
if (output.solver.status !== 'OPTIMAL') throw new Error(`bid optimization ${output.solver.status}: ${output.binding_constraints.join('; ')}`)
const lin = ctx.lineage.current()
ctx.caseDesk.addEvidence(caseId, lin.data_refs)
return {
market_date: inputData.market_date,
case_id: caseId,
tool_calls: [...calls, ref].filter((c, i, arr) => arr.findIndex((x) => x.tool_call_id === c.tool_call_id) === i),
data_refs: lin.data_refs,
milp_call: ref,
milp_output: output,
ledger_version: bounds.ledger_version,
policy_pack_version: ctx.policy.current(ctx.config.policyPackId).version,
capacity,
}
})
},
})
const bidStrategy = createStep({
id: 'bid-strategy',
inputSchema: Context,
outputSchema: Context.extend({ draft: BidProposalDraftSchema, llm_used: z.boolean() }),
execute: async ({ inputData }) => {
const tc = inputData.milp_call.tool_call_id
const out = inputData.milp_output as { expected_revenue_yuan: string; daily_energy_mwh: string; binding_constraints: string[] }
const template: BidProposalDraft = {
market_date: inputData.market_date,
prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' },
quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' },
expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' },
rationale: `Solver ${tc}: expected revenue ${out.expected_revenue_yuan} yuan over ${out.daily_energy_mwh} MWh; binding: ${out.binding_constraints.join(', ') || 'none'} (template — LLM unavailable)`,
}
try {
const prompt =
`Market date ${inputData.market_date}. The bid-optimization tool call id is ${tc}; its output has fields prices_yuan_per_mwh, quantities_mwh, expected_revenue_yuan (= ${out.expected_revenue_yuan}), daily_energy_mwh (= ${out.daily_energy_mwh}), binding_constraints (${out.binding_constraints.join(', ') || 'none'}). ` +
'Return a BidProposalDraft whose three refs point at those fields of that tool call, plus a one-paragraph rationale for the approver. Do not write any number that is not quoted above.'
const draft = await ctx.llm.structured('trading-agent', prompt, BidProposalDraftSchema)
return { ...inputData, draft, llm_used: true }
} catch (e) {
if (!(e instanceof LlmUnavailable)) throw e
return { ...inputData, draft: template, llm_used: false }
}
},
})
const assembleAndSubmit = createStep({
id: 'assemble-and-submit',
inputSchema: bidStrategy.outputSchema,
outputSchema: BidOutput,
execute: async ({ inputData }) => {
const proposal: Proposal = assembleBidProposal(inputData.draft, {
id: ctx.newId('prop'),
originator: { agent: 'trading-agent', trigger: 'SCHEDULED', task_id: inputData.case_id },
lineage: { tool_calls: inputData.tool_calls as ToolCallRef[], data_refs: inputData.data_refs },
ledger_version: inputData.ledger_version,
policy_pack_version: inputData.policy_pack_version,
snapshots: ctx.snapshots,
now: ctx.clock(),
})
ctx.snapshots.put({ draft: inputData.draft, proposal_id: proposal.id })
ctx.caseDesk.recordProposal(inputData.case_id, proposal)
// ProposalSubmissionPort (docs/11 §1.1): the business workflow ends here;
// the lifecycle workflow takes over on its own run id (resumable).
const run = await lifecycle.createRun()
const result = await run.start({ inputData: { proposal, case_id: inputData.case_id } })
if (result.status === 'suspended') {
const pending = ctx.caseDesk.pending.get(proposal.id)
if (pending) ctx.caseDesk.pending.put({ ...pending.value, run_id: run.runId }, pending.version)
}
return {
case_id: inputData.case_id,
proposal_id: proposal.id,
proposal,
lifecycle_run_id: run.runId,
lifecycle: result.status === 'success' ? result.result : null,
lifecycle_status: (result.status === 'success' ? 'success' : result.status === 'suspended' ? 'suspended' : 'failed') as 'success' | 'suspended' | 'failed',
llm_used: inputData.llm_used,
}
},
})
return createWorkflow({
id: 'day-ahead-bid',
inputSchema: BidInput,
outputSchema: BidOutput,
})
.then(fetchContext)
.then(bidStrategy)
.then(assembleAndSubmit)
.commit()
}

View File

@ -0,0 +1,137 @@
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'
import type { ForecastBundle, ForecastKind, SituationReport, ToolCallRef } from '@vpp/domain'
import { ForecastBundle as ForecastBundleSchema, MarketDate, SituationReport as SituationReportSchema, ToolCallRef as ToolCallRefSchema } from '@vpp/domain'
import type { RuntimeContext } from '../context.js'
import { LlmUnavailable } from '../llm.js'
import { registerSkill } from '../skills.js'
/**
* D-1 06:00 日前态势 (docs/07): periodic trigger → forecasts (tools) →
* analysis agent writes findings around the numbers → SituationReport on
* the bus. Runs with the LLM down (I6): findings fall back to a template.
*/
export const SituationInput = z.object({ market_date: MarketDate })
export const SituationOutput = z.object({
report: SituationReportSchema,
report_ref: z.string(),
forecasts: z.object({ LOAD: ForecastBundleSchema, PV: ForecastBundleSchema, PRICE: ForecastBundleSchema }),
tool_calls: z.array(ToolCallRefSchema),
llm_backend: z.string(),
llm_used: z.boolean(),
})
const SERIES: Record<ForecastKind, { series: string; unit: 'mw' | 'yuan_per_mwh' }> = {
LOAD: { series: 'load:aggregate', unit: 'mw' },
PV: { series: 'pv:aggregate', unit: 'mw' },
PRICE: { series: 'price:da', unit: 'yuan_per_mwh' },
}
const Findings = z.object({
findings: z.array(z.object({ kind: z.enum(['TREND', 'ANOMALY', 'RISK', 'ATTRIBUTION']), summary: z.string().min(1) })).min(1).max(6),
})
export async function runForecasts(ctx: RuntimeContext, marketDate: string): Promise<{ forecasts: Record<ForecastKind, ForecastBundle>; calls: ToolCallRef[] }> {
const d = new Date(`${marketDate}T00:00:00Z`)
const from = new Date(d.getTime() - ctx.config.forecastWindowDays * 86_400_000).toISOString().slice(0, 10)
const to = new Date(d.getTime() - 86_400_000).toISOString().slice(0, 10)
const forecasts = {} as Record<ForecastKind, ForecastBundle>
const calls: ToolCallRef[] = []
for (const kind of ['LOAD', 'PV', 'PRICE'] as const) {
const history = ctx.timeseries.range(SERIES[kind].series, from, to).map((r) => r.curve)
if (history.length === 0) throw new Error(`no history for ${SERIES[kind].series} in [${from}, ${to}] — ingest data first`)
const featuresRef = ctx.snapshots.put({ series: SERIES[kind].series, from, to, revisions: history.length })
ctx.lineage.addDataRef(featuresRef)
const skill = registerSkill(
{ lineage: ctx.lineage, snapshots: ctx.snapshots, idGen: () => ctx.newId('tc') },
{ id: `${kind.toLowerCase()}-forecast`, version: '1.0.0', invoke: (req: Parameters<typeof ctx.skills.forecast>[1]) => ctx.skills.forecast(kind, req) },
)
const { output, ref } = await skill.call({ kind, market_date: marketDate, unit: SERIES[kind].unit, history, exogenous: {}, features_snapshot_ref: featuresRef })
forecasts[kind] = output
calls.push(ref)
}
return { forecasts, calls }
}
/** Risk level is deterministic (it gates the abnormal-day protocol, docs/13 §1); the LLM only narrates. */
export function riskLevel(ctx: RuntimeContext, price: ForecastBundle): SituationReport['risk_level'] {
const ratio = Math.max(...price.quantiles.p90.values.map((v, i) => Number(v) / Math.max(Number(price.quantiles.p50.values[i]), 1e-9)))
const extreme = Number(ctx.config.extremeDayPriceRatio)
if (ratio >= extreme) return 'EXTREME'
if (ratio >= 1 + (extreme - 1) * 0.66) return 'HIGH'
if (ratio >= 1 + (extreme - 1) * 0.33) return 'MEDIUM'
return 'LOW'
}
export function createDayAheadSituation(ctx: RuntimeContext) {
const forecastStep = createStep({
id: 'forecast',
inputSchema: SituationInput,
outputSchema: z.object({ market_date: MarketDate, forecasts: SituationOutput.shape.forecasts, tool_calls: z.array(ToolCallRefSchema) }),
execute: async ({ inputData }) => {
return ctx.lineage.run(async () => {
const { forecasts, calls } = await runForecasts(ctx, inputData.market_date)
return { market_date: inputData.market_date, forecasts, tool_calls: calls }
})
},
})
const analyzeStep = createStep({
id: 'analyze',
inputSchema: forecastStep.outputSchema,
outputSchema: SituationOutput,
execute: async ({ inputData }) => {
const { forecasts, tool_calls } = inputData
const refs = tool_calls.map((t) => t.outputs_ref)
const level = riskLevel(ctx, forecasts.PRICE)
const peakPrice = Math.max(...forecasts.PRICE.quantiles.p50.values.map(Number))
const peakLoad = Math.max(...forecasts.LOAD.quantiles.p50.values.map(Number))
let findings: SituationReport['findings']
let llmUsed = false
const fallback: SituationReport['findings'] = [
{ kind: 'TREND', summary: `P50 peak load ${peakLoad.toFixed(1)} MW; P50 peak price ${peakPrice.toFixed(2)} yuan/MWh (template — LLM unavailable)`, refs },
{ kind: 'RISK', summary: `risk level ${level} from price band width`, refs },
]
try {
const prompt =
`Market date ${inputData.market_date}. Tool outputs (snapshot refs): ` +
tool_calls.map((t) => `${t.tool}→${t.outputs_ref.slice(0, 12)}`).join(', ') +
`. Deterministic risk level: ${level}. P50 peak load ${peakLoad.toFixed(1)} MW, P50 peak price ${peakPrice.toFixed(2)} yuan/MWh. ` +
'Write 2–4 findings (TREND/ANOMALY/RISK) that only restate these numbers.'
const out = await ctx.llm.structured('analysis-agent', prompt, Findings)
findings = out.findings.map((f) => ({ ...f, refs }))
llmUsed = true
} catch (e) {
if (!(e instanceof LlmUnavailable)) throw e
findings = fallback
}
const report: SituationReport = {
id: ctx.newId('sit'),
market_date: inputData.market_date,
risk_level: level,
findings,
forecast_refs: [forecasts.LOAD.id, forecasts.PV.id, forecasts.PRICE.id],
generated_at: ctx.clock(),
}
const report_ref = ctx.snapshots.put(report)
ctx.events.append({
event_type: 'SituationPublished',
payload: { report, report_ref, forecast_tool_calls: tool_calls },
correlation_id: `situation-${inputData.market_date}`,
})
return { report, report_ref, forecasts, tool_calls, llm_backend: ctx.llm.backend, llm_used: llmUsed }
},
})
return createWorkflow({
id: 'day-ahead-situation',
inputSchema: SituationInput,
outputSchema: SituationOutput,
})
.then(forecastStep)
.then(analyzeStep)
.commit()
}

View File

@ -0,0 +1,247 @@
import { createStep, createWorkflow } from '@mastra/core/workflows'
import { z } from 'zod'
import type { Approval, ForecastBundle, Proposal, ProposalStatus } from '@vpp/domain'
import { Approval as ApprovalSchema, ForecastBundle as ForecastBundleSchema, Proposal as ProposalSchema } from '@vpp/domain'
import { GatewayRejected, assertApproverIndependent, isStaleDenial } from '@vpp/services'
import type { RuntimeContext } from '../context.js'
/**
* The safety chain as ONE workflow for every proposal type (ADR-0005,
* docs/03 §2, docs/09 §2): rule check → simulation → envelope gate
* (suspend for humans) → fresh check + permit → release. Every transition is
* recorded through the case desk (event-sourced) so the run is replayable.
*/
export const LifecycleInput = z.object({ proposal: ProposalSchema, case_id: z.string().min(1) })
export const LifecycleOutcome = z.enum(['RELEASED', 'REJECTED', 'STALE', 'SIMULATION_ALERT'])
export const LifecycleOutput = z.object({
outcome: LifecycleOutcome,
proposal: ProposalSchema,
permit_id: z.string().nullable(),
receipt_id: z.string().nullable(),
reasons: z.array(z.string()),
})
const Carry = z.object({
proposal: ProposalSchema,
case_id: z.string(),
simulation_alert: z.boolean(),
simulation_alerts: z.array(z.string()),
approvals: z.array(ApprovalSchema),
envelope_match: z.unknown().nullable(),
done: LifecycleOutput.nullable(),
})
type Carry = z.infer<typeof Carry>
export const ResumeDecision = z.object({
decision: z.enum(['approve', 'reject']),
approver: z.object({ id: z.string().min(1), role: z.string().min(1) }),
comment: z.string().optional(),
})
export type ResumeDecision = z.infer<typeof ResumeDecision>
const SuspendInfo = z.object({
reason: z.string(),
proposal_id: z.string(),
required_level: z.string(),
reasons: z.array(z.string()),
})
function transition(ctx: RuntimeContext, caseId: string, p: Proposal, status: ProposalStatus, extra: Record<string, unknown> = {}): Proposal {
const next: Proposal = { ...p, status }
ctx.caseDesk.recordProposal(caseId, next)
if (Object.keys(extra).length) {
ctx.events.append({ event_type: `Lifecycle.${status}`, payload: { proposal_id: p.id, digest: p.digest, ...extra }, correlation_id: caseId, causation_id: p.id })
}
return next
}
function priceForecastFromLineage(ctx: RuntimeContext, p: Proposal): ForecastBundle | null {
for (const tc of [...p.lineage.tool_calls].reverse()) {
if (tc.tool !== 'price-forecast') continue
const parsed = ForecastBundleSchema.safeParse(ctx.snapshots.get(tc.outputs_ref))
if (parsed.success) return parsed.data
}
return null
}
const finish = (c: Carry, outcome: z.infer<typeof LifecycleOutcome>, reasons: string[], permit_id: string | null = null, receipt_id: string | null = null): Carry => ({
...c,
done: { outcome, proposal: c.proposal, permit_id, receipt_id, reasons },
})
/** One lifecycle workflow per runtime: steps close over that runtime's services. */
export function createProposalLifecycle(ctx: RuntimeContext) {
const ruleCheck = createStep({
id: 'rule-check',
inputSchema: LifecycleInput,
outputSchema: Carry,
execute: async ({ inputData }) => {
const pack = ctx.policy.pack(ctx.config.policyPackId, inputData.proposal.lineage.policy_pack_version)
let p = transition(ctx, inputData.case_id, inputData.proposal, 'RULE_CHECK')
const result = ctx.policy.check(p, { id: pack.id, version: pack.version }, { ledger: ctx.ledger.read(), ledgerService: ctx.ledger, snapshots: ctx.snapshots, now: ctx.clock() })
const ref = ctx.snapshots.put(result)
ctx.events.append({ event_type: 'RuleCheckCompleted', payload: { proposal_id: p.id, ok: result.ok, result_ref: ref, violations: result.violations }, correlation_id: inputData.case_id, causation_id: p.id })
const carry: Carry = { proposal: p, case_id: inputData.case_id, simulation_alert: false, simulation_alerts: [], approvals: [], envelope_match: null, done: null }
if (!result.ok) {
p = transition(ctx, inputData.case_id, p, 'REJECTED', { by: 'rule-check', violations: result.violations })
return finish({ ...carry, proposal: p }, 'REJECTED', result.violations.map((v) => `${v.rule_id}: ${v.message}`))
}
return carry
},
})
const simulation = createStep({
id: 'simulation',
inputSchema: Carry,
outputSchema: Carry,
execute: async ({ inputData }) => {
if (inputData.done) return inputData
const p = transition(ctx, inputData.case_id, inputData.proposal, 'SIMULATION')
const result = ctx.simulation.simulate(p, {
now: ctx.clock(),
priceForecast: priceForecastFromLineage(ctx, p),
marginalCostYuanPerMwh: ctx.config.risk.marginal_cost_yuan_per_mwh,
worstCaseLossBudgetYuan: ctx.config.worstCaseLossBudgetYuan,
})
const ref = ctx.snapshots.put(result)
ctx.events.append({ event_type: 'SimulationCompleted', payload: { proposal_id: p.id, alert: result.alert, alerts: result.alerts, metrics: result.metrics, result_ref: ref }, correlation_id: inputData.case_id, causation_id: p.id })
return { ...inputData, proposal: p, simulation_alert: result.alert, simulation_alerts: result.alerts }
},
})
const envelopeGate = createStep({
id: 'envelope-gate',
inputSchema: Carry,
outputSchema: Carry,
suspendSchema: SuspendInfo,
resumeSchema: ResumeDecision,
execute: async ({ inputData, resumeData, suspend }) => {
if (inputData.done) return inputData
const now = ctx.clock()
const p0 = inputData.proposal
if (resumeData) {
// —— Human decision path (I1/I2 enforced here; the AI has no route to APPROVED).
try {
assertApproverIndependent(p0, resumeData.approver, ctx.config.approverRoles)
} catch (e) {
ctx.events.append({ event_type: 'ApprovalRefused', payload: { proposal_id: p0.id, approver: resumeData.approver, reason: (e as Error).message }, correlation_id: inputData.case_id, causation_id: p0.id })
return await suspend({ reason: `approval refused: ${(e as Error).message}`, proposal_id: p0.id, required_level: 'L2', reasons: [(e as Error).message] })
}
const approval: Approval = {
id: ctx.newId('appr'),
proposal_digest: p0.digest,
decision: resumeData.decision === 'approve' ? 'APPROVE' : 'REJECT',
approver: resumeData.approver,
scope: { effect_type: p0.type, limits: {} },
validity: { from: now, to: new Date(new Date(now).getTime() + ctx.config.bidPermitTtlMs).toISOString() },
evidence_versions: { policy_pack_version: p0.lineage.policy_pack_version, ledger_version: ctx.ledger.read().version, data_snapshot_refs: p0.lineage.data_refs },
...(resumeData.comment ? { comment: resumeData.comment } : {}),
decided_at: now,
}
ctx.caseDesk.recordApproval(inputData.case_id, approval)
ctx.caseDesk.clearPending(p0.id)
if (approval.decision === 'REJECT') {
const p = transition(ctx, inputData.case_id, p0, 'REJECTED', { by: approval.approver.id, comment: resumeData.comment ?? null })
return finish({ ...inputData, proposal: p, approvals: [approval] }, 'REJECTED', [`rejected by ${approval.approver.id}`])
}
const p = transition(ctx, inputData.case_id, p0, 'APPROVED', { by: approval.approver.id })
return { ...inputData, proposal: p, approvals: [approval] }
}
// —— Automatic path.
let p = transition(ctx, inputData.case_id, p0, 'ENVELOPE_CHECK')
if (inputData.simulation_alert) {
// One vote: any simulation alert escalates to a human, no exceptions.
p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'simulation alert', alerts: inputData.simulation_alerts })
ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: 'L2', reasons: inputData.simulation_alerts, since: now })
return await suspend({ reason: 'simulation alert', proposal_id: p.id, required_level: 'L2', reasons: inputData.simulation_alerts })
}
const match = ctx.envelopes.match(p, { now, priceBaseline: priceForecastFromLineage(ctx, p)?.quantiles.p50 ?? null })
ctx.snapshots.put(match)
if (match.within) {
p = transition(ctx, inputData.case_id, { ...p, envelope_ref: match.envelope_id }, 'AUTO_APPROVED', { envelope_id: match.envelope_id })
return { ...inputData, proposal: p, envelope_match: match }
}
p = transition(ctx, inputData.case_id, p, 'PENDING_HUMAN', { because: 'outside envelope', reasons: match.reasons })
ctx.caseDesk.requestApproval({ proposal_id: p.id, proposal_digest: p.digest, case_id: inputData.case_id, run_id: '', required_level: match.required_level, reasons: match.reasons, since: now })
return await suspend({ reason: 'outside envelope', proposal_id: p.id, required_level: match.required_level, reasons: match.reasons })
},
})
const freshCheckAndPermit = createStep({
id: 'fresh-check',
inputSchema: Carry,
outputSchema: Carry,
execute: async ({ inputData }) => {
if (inputData.done) return inputData
const now = ctx.clock()
let p = transition(ctx, inputData.case_id, inputData.proposal, 'FRESH_CHECK')
const result = ctx.authority.authorize(
p,
{ approvals: inputData.approvals, envelopeMatch: (inputData.envelope_match as Parameters<typeof ctx.authority.authorize>[1]['envelopeMatch']) ?? null },
{ now, ledger: ctx.ledger, currentPolicyPackVersion: ctx.policy.current(ctx.config.policyPackId).version, offlineResources: [], permitTtlMs: ctx.config.bidPermitTtlMs },
)
if (isStaleDenial(result)) {
p = transition(ctx, inputData.case_id, p, 'STALE', { reasons: result.reasons })
return finish({ ...inputData, proposal: p }, 'STALE', result.reasons)
}
ctx.caseDesk.recordPermit(inputData.case_id, result)
p = transition(ctx, inputData.case_id, p, 'AUTHORIZED', { permit_id: result.id, expires_at: result.expires_at })
return { ...inputData, proposal: p, done: null, envelope_match: inputData.envelope_match, approvals: inputData.approvals, permit: result } as Carry & { permit: unknown }
},
})
const release = createStep({
id: 'release',
inputSchema: Carry.extend({ permit: z.unknown().optional() }),
outputSchema: LifecycleOutput,
execute: async ({ inputData }) => {
if (inputData.done) return inputData.done
const now = ctx.clock()
const permitId = (inputData.permit as { id: string } | undefined)?.id
const permit = permitId ? ctx.authority.permits.get(permitId)?.value : undefined
if (!permit) throw new Error('release reached without a permit — invariant I4 violated in workflow wiring')
try {
const receipt = ctx.gateway.dispatch(inputData.proposal, permit, now)
const p = transition(ctx, inputData.case_id, inputData.proposal, 'RELEASED', { receipt_id: receipt.receipt_id, artifact_ref: receipt.artifact_ref })
ctx.events.append({ event_type: 'ExecutionReceipt', payload: receipt, correlation_id: inputData.case_id, causation_id: p.id })
ctx.ledger.append({
id: ctx.newId('pos'),
timescale: 'DAY_AHEAD',
period: p.type === 'BID' ? p.payload.market_date : now.slice(0, 10),
kind: 'BID_SUBMITTED',
energy_mwh: p.type === 'BID' ? p.payload.quantities_mwh.values.reduce((s, v) => (Number(s) + Number(v)).toFixed(3), '0') : '0',
curve: p.type === 'BID' ? p.payload.quantities_mwh : null,
source_ref: p.digest,
expected_version: ctx.ledger.read().version,
})
ctx.caseDesk.close(inputData.case_id, receipt.receipt_id, true)
return { outcome: 'RELEASED' as const, proposal: p, permit_id: permit.id, receipt_id: receipt.receipt_id, reasons: [] }
} catch (e) {
if (e instanceof GatewayRejected) {
const p = transition(ctx, inputData.case_id, inputData.proposal, 'STALE', { gateway_reasons: e.reasons })
return { outcome: 'STALE' as const, proposal: p, permit_id: permit.id, receipt_id: null, reasons: e.reasons }
}
throw e
}
},
})
return createWorkflow({
id: 'proposal-lifecycle',
inputSchema: LifecycleInput,
outputSchema: LifecycleOutput,
})
.then(ruleCheck)
.then(simulation)
.then(envelopeGate)
.then(freshCheckAndPermit)
.then(release)
.commit()
}
export const ENVELOPE_GATE_STEP_ID = 'envelope-gate'

View File

@ -0,0 +1,54 @@
import type { AddressInfo } from 'node:net'
import { describe, expect, it } from 'vitest'
import { createApi } from '../src/api.js'
import { TriggerService } from '../src/trigger.js'
import { MARKET_DATE, harness } from './helpers.js'
async function serve(h: Awaited<ReturnType<typeof harness>>) {
const server = createApi(h.rt, new TriggerService(h.rt))
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const call = async (method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<{ status: number; body: any }> => {
const init: RequestInit = { method, headers: { 'content-type': 'application/json', ...headers } }
if (body !== undefined) init.body = JSON.stringify(body)
const res = await fetch(base + path, init)
return { status: res.status, body: await res.json() }
}
return { call, close: () => new Promise<void>((r) => server.close(() => r())) }
}
describe('Case Desk HTTP API', () => {
it('inbox → decide (approve) → case view + lineage expansion', async () => {
const h = await harness({ llm: null })
const { call, close } = await serve(h)
expect((await call('GET', '/health')).body).toEqual({ status: 'ok', llm: 'null' })
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
const out = await run.start({ inputData: { market_date: MARKET_DATE } })
if (out.status !== 'success') throw new Error(out.status)
const inbox = await call('GET', '/inbox')
expect(inbox.body).toHaveLength(1)
const { run_id, proposal_id, case_id } = inbox.body[0]
expect((await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' })).status).toBe(401)
const refused = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve' }, { 'x-user-id': 'trading-agent', 'x-user-role': 'senior-trader' })
expect(refused.body.status).toBe('suspended') // I1: still waiting for a legitimate human
const ok = await call('POST', `/approvals/${run_id}/decide`, { decision: 'approve', comment: 'fine' }, { 'x-user-id': 'user-trader-01', 'x-user-role': 'senior-trader' })
expect(ok.body).toMatchObject({ run_id, status: 'success', outcome: 'RELEASED' })
expect((await call('GET', '/inbox')).body).toHaveLength(0)
const view = await call('GET', `/cases/${case_id}`)
expect(view.body.case.status).toBe('CLOSED_DONE')
expect(view.body.approvals[0].approver.id).toBe('user-trader-01')
const lineage = await call('GET', `/proposals/${proposal_id}/lineage`)
expect(lineage.body.tool_calls.map((t: { tool: string }) => t.tool)).toContain('bid-optimization-milp')
expect((await call('GET', `/events?correlation_id=${case_id}`)).body.length).toBeGreaterThan(8)
expect((await call('GET', '/nope')).status).toBe(404)
expect((await call('POST', '/tasks', { message: 'hi' })).status).toBe(503) // router needs an LLM
await close()
await h.rt.close()
})
})

View File

@ -0,0 +1,102 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { BidOptimizationRequest, BidOptimizationResult, Envelope, ForecastBundle, ForecastKind, ForecastRequest, ReportRequest, SkillReport } from '@vpp/domain'
import { MemoryTimeSeriesStore } from '@vpp/services'
import type { SkillClient } from '@vpp/services'
import { createRuntime } from '../src/runtime.js'
import type { RuntimeOptions } from '../src/runtime.js'
import type { LlmPort } from '../src/llm.js'
const datasetPath = fileURLToPath(new URL('../../evals/datasets/synthetic-hubei-v0.json', import.meta.url))
interface Day { date: string; load_mw: string[]; pv_mw: string[]; price_yuan_per_mwh: string[]; adjustable_capacity_mw: string[] }
export const MARKET_DATE = '2026-03-15'
export const NOW = '2026-03-14T00:00:00Z' // D-1 08:00 Asia/Shanghai
/**
* Deterministic stand-in for the Python skill service: forecast = last
* history day with a ±spread band; bid = flat energy at the position's
* lower bound offered at `offer` — enough to steer the lifecycle down every
* branch (envelope in/out, simulation alert) from tests.
*/
export class StubSkills implements SkillClient {
spread = 0.1
offer = '0.00'
energyFraction = 0 // 0 → E_min, 1 → E_max
calls: string[] = []
async skills() {
return ['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'].map((id) => ({ id, version: '0.0.1', endpoint: '/stub' }))
}
async forecast(kind: ForecastKind, req: ForecastRequest): Promise<ForecastBundle> {
this.calls.push(`forecast:${kind}`)
const last = req.history[req.history.length - 1]!
const scaled = (f: number) => ({ interval_minutes: 15 as const, date: req.market_date, values: last.values.map((v) => (Number(v) * f).toFixed(kind === 'PRICE' ? 2 : 3)) })
return {
id: `fc-${kind.toLowerCase()}-${req.market_date}`, kind, market_date: req.market_date, unit: req.unit,
quantiles: { p10: scaled(1 - this.spread), p50: scaled(1), p90: scaled(1 + this.spread) },
model: { name: `${kind.toLowerCase()}-forecast`, version: '0.0.1' }, features_snapshot_ref: req.features_snapshot_ref, generated_at: '2026-03-14T06:00:00Z',
}
}
async optimizeBid(req: BidOptimizationRequest): Promise<BidOptimizationResult> {
this.calls.push('milp')
const lo = Number(req.position_bounds.daily_energy_min_mwh)
const hi = Number(req.position_bounds.daily_energy_max_mwh)
const per = ((lo + (hi - lo) * this.energyFraction) / 96).toFixed(3)
const q = { interval_minutes: 15 as const, date: req.market_date, values: Array(96).fill(per) as string[] }
const energy = (Number(per) * 96).toFixed(3)
const p50 = req.price_forecast.quantiles.p50.values
const revenue = p50.reduce((s, p) => s + (Number(this.offer) <= Number(p) ? Number(p) * Number(per) : 0), 0).toFixed(2)
return {
market_date: req.market_date, prices_yuan_per_mwh: { ...q, values: Array(96).fill(this.offer) }, quantities_mwh: q, daily_energy_mwh: energy,
expected_revenue_yuan: revenue, revenue_distribution_yuan: { p10: revenue, p50: revenue, p90: revenue }, position_bounds: req.position_bounds,
solver: { name: 'stub', version: '0', status: 'OPTIMAL', objective_value: revenue, wall_time_ms: 1 }, binding_constraints: ['daily_energy_min'], skill_version: '0.0.1',
}
}
async report(_r: ReportRequest): Promise<SkillReport> { throw new Error('not used') }
}
export function seededTimeseries(clock: () => string) {
const ts = new MemoryTimeSeriesStore(clock)
const days = (JSON.parse(readFileSync(datasetPath, 'utf8')) as { days: Day[] }).days
for (const d of days) {
ts.write('load:aggregate', { interval_minutes: 15, date: d.date, values: d.load_mw }, 'synthetic')
ts.write('pv:aggregate', { interval_minutes: 15, date: d.date, values: d.pv_mw }, 'synthetic')
ts.write('price:da', { interval_minutes: 15, date: d.date, values: d.price_yuan_per_mwh }, 'synthetic')
}
return ts
}
export const envelope = (bounds: Record<string, string>, over: Partial<Envelope> = {}): Envelope => ({
id: 'env-bid-001',
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
bounds,
validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' },
approval: { level: 'L1', approved_by: ['user-ops-lead'] },
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
status: 'ACTIVE',
...over,
})
export interface Harness { rt: Awaited<ReturnType<typeof createRuntime>>; skills: StubSkills; dataDir: string; setNow: (iso: string) => void }
/** Fresh runtime on a temp dir, seeded like docs/07 D-1: history, one storage resource, a monthly contract. */
export async function harness(opts: { llm?: LlmPort | null; dataDir?: string; seed?: boolean; config?: RuntimeOptions['config'] } = {}): Promise<Harness> {
let now = NOW
const clock = () => now
const skills = new StubSkills()
const dataDir = opts.dataDir ?? mkdtempSync(join(tmpdir(), 'vpp-rt-'))
const rt = await createRuntime({ dataDir, llm: opts.llm ?? null, skills, clock, timeseries: seededTimeseries(clock), ...(opts.config ? { config: opts.config } : {}) })
if (opts.seed !== false) {
rt.ctx.resources.put({
resource_id: 'res-storage-01', name: 'Wuhan storage #1', type: 'STORAGE', rated_power_mw: '30.0', certified_adjustable_mw: '24.0', confidence: '0.9', reliability_score: '0.9',
constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' }, evidence_refs: [], updated_at: NOW,
})
// 24 MW × 0.9 × 0.25 h × 96 = 518.4 MWh sellable/day; contract 60% of it → daily share 311.04, band ±5%
rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 })
}
return { rt, skills, dataDir, setNow: (iso) => (now = iso) }
}

View File

@ -0,0 +1,292 @@
import { existsSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import type { Proposal } from '@vpp/domain'
import { proposalDigest } from '@vpp/services'
import { ScriptedLlm } from '../src/llm.js'
import { TriggerService } from '../src/trigger.js'
import { MARKET_DATE, envelope, harness } from './helpers.js'
const APPROVER = { id: 'user-trader-01', role: 'senior-trader' }
async function submitBid(h: Awaited<ReturnType<typeof harness>>) {
const wf = h.rt.mastra.getWorkflow('dayAheadBid')
const run = await wf.createRun()
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
if (res.status !== 'success') throw new Error(`bid workflow ${res.status}: ${JSON.stringify((res as { error?: unknown }).error ?? res)}`)
return res.result
}
const statuses = (h: Awaited<ReturnType<typeof harness>>, caseId: string) =>
h.rt.ctx.events.list({ event_type: 'ProposalStatusChanged', correlation_id: caseId }).map((e) => (e.payload as { status: string }).status)
describe('docs/07 timeline D-1 06:00 → 08:30 (LLM down: I6)', () => {
it('situation → bid → lifecycle end-to-end, auto-approved inside the envelope, released as a bid file', async () => {
const h = await harness({ llm: null })
h.rt.ctx.envelopes.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' }))
// 06:00 situation
const sit = await h.rt.mastra.getWorkflow('dayAheadSituation').createRun()
const sres = await sit.start({ inputData: { market_date: MARKET_DATE } })
expect(sres.status).toBe('success')
if (sres.status !== 'success') return
expect(sres.result.llm_used).toBe(false)
expect(sres.result.report.findings[0]!.summary).toMatch(/template/)
expect(sres.result.report.risk_level).toBe('LOW')
expect(h.rt.ctx.events.list({ event_type: 'SituationPublished' })).toHaveLength(1)
// 08:00 bid → 08:30 chain
const out = await submitBid(h)
expect(out.llm_used).toBe(false)
expect(out.lifecycle_status).toBe('success')
expect(out.lifecycle?.outcome).toBe('RELEASED')
expect(statuses(h, out.case_id)).toEqual(['DRAFT', 'RULE_CHECK', 'SIMULATION', 'ENVELOPE_CHECK', 'AUTO_APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED'])
// P2: every payload number traces to the MILP tool output; P7: the ledger recorded the bid.
const lin = h.rt.ctx.caseDesk.expandLineage(out.proposal_id)
expect(lin.tool_calls.map((t) => t.tool)).toEqual(['load-forecast', 'pv-forecast', 'price-forecast', 'bid-optimization-milp'])
expect((lin.tool_calls[3]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe(out.proposal.payload.expected_revenue_yuan)
expect(h.rt.ctx.ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1)
expect(out.proposal.lineage.ledger_version).toBe(1)
// I5: receipt + file; case closed with the receipt as outcome.
const view = h.rt.ctx.caseDesk.read(out.case_id)
expect(view.case.status).toBe('CLOSED_DONE')
expect(view.permits).toHaveLength(1)
expect(existsSync(h.rt.ctx.gateway.receipts.list()[0]!.value.artifact_ref)).toBe(true)
await h.rt.close()
})
it('with an LLM, the agents narrate but the numbers are byte-identical to the LLM-down run', async () => {
const draftFor = (prompt: string) => {
const tc = /tool call id is (tc-[\w-]+)/.exec(prompt)![1]!
return { market_date: MARKET_DATE, prices_ref: { tool_call_id: tc, path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: tc, path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: tc, path: 'expected_revenue_yuan' }, rationale: 'Evening peak carries the position; offer as price-taker.' }
}
const llm = new ScriptedLlm({
'analysis-agent': { findings: [{ kind: 'TREND', summary: 'Load near seasonal norm; no anomalies.' }] },
'trading-agent': draftFor,
})
const withLlm = await harness({ llm })
const without = await harness({ llm: null })
for (const h of [withLlm, without]) h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
const a = await submitBid(withLlm)
const b = await submitBid(without)
expect(a.llm_used).toBe(true)
expect(b.llm_used).toBe(false)
expect(a.proposal.payload).toEqual(b.proposal.payload)
expect(llm.prompts.map((p) => p.agentId)).toContain('trading-agent')
await withLlm.rt.close()
await without.rt.close()
})
it('an LLM draft that references a tool call outside lineage cannot become a proposal (P2)', async () => {
const llm = new ScriptedLlm({
'trading-agent': { market_date: MARKET_DATE, prices_ref: { tool_call_id: 'tc-forged', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-forged', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-forged', path: 'expected_revenue_yuan' }, rationale: 'x' },
})
const h = await harness({ llm })
const run = await h.rt.mastra.getWorkflow('dayAheadBid').createRun()
const res = await run.start({ inputData: { market_date: MARKET_DATE } })
expect(res.status).toBe('failed')
if (res.status === 'failed') expect(String(res.error.message)).toMatch(/not in this proposal's lineage/)
expect(h.rt.ctx.caseDesk.proposals.list()).toHaveLength(0)
await h.rt.close()
})
})
describe('human approval path (PENDING_HUMAN → resume)', () => {
it('suspends outside the envelope, surfaces in the inbox, and a human approve releases it', async () => {
const h = await harness({ llm: null }) // no envelopes → everything manual
const out = await submitBid(h)
expect(out.lifecycle_status).toBe('suspended')
expect(statuses(h, out.case_id).at(-1)).toBe('PENDING_HUMAN')
const inbox = h.rt.ctx.caseDesk.inbox()
expect(inbox).toHaveLength(1)
expect(inbox[0]!.run_id).toBe(out.lifecycle_run_id)
expect(inbox[0]!.reasons[0]).toMatch(/no ACTIVE envelope/)
const triggers = new TriggerService(h.rt)
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER, comment: 'ok' })
expect(res.status).toBe('success')
if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED')
expect(statuses(h, out.case_id).slice(-4)).toEqual(['APPROVED', 'FRESH_CHECK', 'AUTHORIZED', 'RELEASED'])
const view = h.rt.ctx.caseDesk.read(out.case_id)
expect(view.approvals[0]!.approver).toEqual(APPROVER)
expect(view.approvals[0]!.proposal_digest).toBe(out.proposal.digest)
await h.rt.close()
})
it('I1: the AI (or anyone in the origination chain) cannot approve; the run stays suspended', async () => {
const h = await harness({ llm: null })
const out = await submitBid(h)
const triggers = new TriggerService(h.rt)
for (const approver of [{ id: 'trading-agent', role: 'senior-trader' }, { id: 'user-x', role: 'agent' }, { id: 'bid-optimization-milp', role: 'ops-lead' }]) {
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver })
expect(res.status).toBe('suspended')
}
expect(h.rt.ctx.events.list({ event_type: 'ApprovalRefused' })).toHaveLength(3)
expect(h.rt.ctx.caseDesk.read(out.case_id).approvals).toHaveLength(0)
const still = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status
expect(still).toBe('PENDING_HUMAN')
await h.rt.close()
})
it('a human reject ends the run as REJECTED', async () => {
const h = await harness({ llm: null })
const out = await submitBid(h)
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'reject', approver: APPROVER, comment: 'too aggressive' })
expect(res.status).toBe('success')
if (res.status === 'success') expect(res.result.outcome).toBe('REJECTED')
expect(h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('REJECTED')
await h.rt.close()
})
it('a simulation alert escalates to a human even inside the envelope (one vote)', async () => {
const h = await harness({ llm: null, config: { worstCaseLossBudgetYuan: '0', risk: { risk_aversion: '0.3', commitment_buffer_k: '0.9', min_block_mwh: '0.5', marginal_cost_yuan_per_mwh: '10000' } } })
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
const out = await submitBid(h)
expect(out.lifecycle_status).toBe('suspended')
expect(h.rt.ctx.caseDesk.inbox()[0]!.reasons[0]).toMatch(/loss budget/)
await h.rt.close()
})
})
describe('durability: a suspended approval survives process restart', () => {
it('resumes on a fresh runtime over the same data dir', async () => {
const h1 = await harness({ llm: null })
const out = await submitBid(h1)
expect(out.lifecycle_status).toBe('suspended')
await h1.rt.close()
const h2 = await harness({ llm: null, dataDir: h1.dataDir, seed: false })
expect(h2.rt.ctx.caseDesk.inbox()[0]!.run_id).toBe(out.lifecycle_run_id)
expect(h2.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value.status).toBe('PENDING_HUMAN')
// The ledger is in-memory in phase 1: replay its monthly anchor so the fresh check sees the same version.
h2.rt.ctx.ledger.append({ id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT', energy_mwh: (311.04 * 31).toFixed(3), curve: null, source_ref: 'contract-2026-03-001', expected_version: 0 })
const state = await h2.rt.mastra.getWorkflow('proposalLifecycle').getWorkflowRunById(out.lifecycle_run_id)
expect(state?.status).toBe('suspended')
const res = await new TriggerService(h2.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
expect(res.status).toBe('success')
if (res.status === 'success') expect(res.result.outcome).toBe('RELEASED')
expect(h2.rt.ctx.caseDesk.read(out.case_id).case.status).toBe('CLOSED_DONE')
await h2.rt.close()
})
})
describe('staleness and permits (I3/I4)', () => {
it('approval goes STALE when the ledger moved between approval and fresh check', async () => {
const h = await harness({ llm: null })
const out = await submitBid(h)
h.rt.ctx.ledger.append({ id: 'late', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
// Approval evidence is captured at resume time; the auto path captured version 1 in lineage.
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
expect(res.status).toBe('success')
if (res.status === 'success') {
// The approval itself now references ledger v2 — consistent — so this passes fresh check;
// the *auto-approved* path is the one bound to lineage v1. Exercise it below.
expect(res.result.outcome).toBe('RELEASED')
}
await h.rt.close()
const g = await harness({ llm: null })
g.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
// Move the ledger *during* the run: between fetch-context (lineage v1) and fresh check.
g.rt.ctx.events.subscribe('Lifecycle.AUTO_APPROVED', () => {
g.rt.ctx.ledger.append({ id: 'race', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: g.rt.ctx.ledger.read().version })
})
const out2 = await submitBid(g)
expect(out2.lifecycle?.outcome).toBe('STALE')
expect(out2.lifecycle?.reasons[0]).toMatch(/ledger version 1 at auto-approval, now 2/)
expect(g.rt.ctx.gateway.receipts.list()).toHaveLength(0)
await g.rt.close()
})
it('permit expiry blocks a late release', async () => {
const h = await harness({ llm: null })
const out = await submitBid(h)
// Resume after the permit TTL would already have passed relative to approval: the gate issues
// the permit at `now`, then the gateway is asked at now + TTL + 1s.
const triggers = new TriggerService(h.rt)
h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', () => h.setNow('2026-03-14T06:00:01Z')) // TTL is 6h
const res = await triggers.decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
expect(res.status).toBe('success')
if (res.status === 'success') {
expect(res.result.outcome).toBe('STALE')
expect(res.result.reasons[0]).toMatch(/permit expired/)
}
expect(h.rt.ctx.gateway.receipts.list()).toHaveLength(0)
await h.rt.close()
})
it('a revoked permit is refused by the gateway (kill switch)', async () => {
const h = await harness({ llm: null })
const out = await submitBid(h)
h.rt.ctx.events.subscribe('Lifecycle.AUTHORIZED', (evt) => {
h.rt.ctx.authority.revoke((evt.payload as { permit_id: string }).permit_id, 'L1 kill switch', h.rt.ctx.clock())
})
const res = await new TriggerService(h.rt).decide(out.lifecycle_run_id, { decision: 'approve', approver: APPROVER })
if (res.status === 'success') expect(res.result.reasons[0]).toMatch(/revoked/)
else throw new Error(res.status)
await h.rt.close()
})
})
describe('replay (I7) and authoritative store (I8)', () => {
it('a released proposal re-checks identically from its snapshots and frozen pack version', async () => {
const h = await harness({ llm: null })
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
const out = await submitBid(h)
const stored: Proposal = h.rt.ctx.caseDesk.proposals.get(out.proposal_id)!.value
expect(proposalDigest(stored)).toBe(out.proposal.digest)
const originalCheck = h.rt.ctx.events.list({ event_type: 'RuleCheckCompleted' })[0]!.payload as { result_ref: string }
const recorded = h.rt.ctx.snapshots.get(originalCheck.result_ref) as { ok: boolean; rules_evaluated: string[] }
// Replay against a ledger reconstructed to the lineage version (the bid append moved it to 2).
const replayLedger = h.rt.ctx.ledger
const replay = h.rt.ctx.policy.check({ ...stored, status: 'DRAFT' }, { id: 'hubei-spot-bidding', version: stored.lineage.policy_pack_version }, { ledger: { version: stored.lineage.ledger_version, entries: [] }, ledgerService: replayLedger, snapshots: h.rt.ctx.snapshots, now: h.rt.ctx.clock() })
expect(replay.rules_evaluated).toEqual(recorded.rules_evaluated)
expect(replay.ok).toBe(recorded.ok)
// I8: the ledger (not any agent memory) is the source the proposal cites.
expect(stored.lineage.ledger_version).toBe(1)
expect(h.rt.ctx.ledger.read().entries.find((e) => e.kind === 'BID_SUBMITTED')?.source_ref).toBe(stored.digest)
await h.rt.close()
})
})
describe('trigger service', () => {
it('scheduled entries fire once per local day and need no LLM', async () => {
const h = await harness({ llm: null })
h.rt.ctx.envelopes.register(envelope({ max_energy_mwh: '1500.0' }))
const t = new TriggerService(h.rt)
expect(await t.tick('2026-03-13T21:00:00Z')).toEqual([]) // 05:00 Shanghai
expect(await t.tick('2026-03-13T22:30:00Z')).toEqual(['situation-0600'])
expect(await t.tick('2026-03-14T00:05:00Z')).toEqual(['bid-0800'])
expect(await t.tick('2026-03-14T00:10:00Z')).toEqual([])
expect(h.rt.ctx.events.list({ event_type: 'WorkflowTriggered' }).map((e) => (e.payload as { workflow: string }).workflow)).toEqual(['dayAheadSituation', 'dayAheadBid'])
await h.rt.close()
})
it('manual requests route to a registered template; router refuses when the LLM is down', async () => {
const llm = new ScriptedLlm({ 'router-agent': { workflow_id: 'day-ahead-situation', params: { market_date: MARKET_DATE }, confidence: 'HIGH' } })
const h = await harness({ llm })
const t = new TriggerService(h.rt)
const r = await t.manual('明天的态势报告')
expect(r.decision.workflow_id).toBe('day-ahead-situation')
expect(r.run_id).not.toBeNull()
await h.rt.close()
const down = await harness({ llm: null })
await expect(new TriggerService(down.rt).manual('anything')).rejects.toThrow(/pick a template explicitly/)
await down.rt.close()
})
it('an EXTREME situation opens an abnormal-day case (event trigger)', async () => {
const h = await harness({ llm: null })
h.skills.spread = 1.2 // p90/p50 = 2.2 ≥ extreme ratio 2.0
const t = new TriggerService(h.rt)
t.startEventConsumers()
await t.startTemplate('dayAheadSituation', { market_date: MARKET_DATE }, 'MANUAL', 'test')
const cases = h.rt.ctx.caseDesk.cases.list().map((r) => r.value)
expect(cases.some((c) => c.kind === 'ADHOC_ANALYSIS' && /Abnormal-day/.test(c.objective))).toBe(true)
t.stop()
await h.rt.close()
})
})

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["src", "test"]
}

View File

@ -12,7 +12,8 @@
}, },
"dependencies": { "dependencies": {
"@vpp/domain": "*", "@vpp/domain": "*",
"decimal.js-light": "^2.5.1" "decimal.js-light": "^2.5.1",
"zod": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.4.1", "@types/node": "^26.4.1",

View File

@ -0,0 +1,127 @@
import type { Approval, EnvelopeMatch, ExecutionPermit, Proposal, StaleDenial } from '@vpp/domain'
import { ExecutionPermit as PermitSchema } from '@vpp/domain'
import { Decimal } from './decimal.js'
import type { LedgerService } from './ledger.js'
import { MemoryRepository } from './relational.js'
import type { Repository } from './relational.js'
/**
* Authority service (docs/03 §2.2, I3/I4): fresh-state check + permit issue +
* revocation. Approval ≠ execution: hours may pass, the world changes. The
* permit is the only credential a gateway accepts, short-lived and revocable.
*/
export interface FreshCheckContext {
now: string
ledger: LedgerService
currentPolicyPackVersion: string
/** Resources referenced by the proposal that are currently offline/de-rated. */
offlineResources: string[]
/** Permit lifetime for this proposal type. Bids: until market deadline — OPEN-QUESTION A1. */
permitTtlMs: number
}
export interface ApprovalBasis {
/** Human approvals (may be multiple levels) — or none when auto-approved. */
approvals: Approval[]
/** Envelope match that auto-approved, if any. */
envelopeMatch: EnvelopeMatch | null
}
export interface ApproverIdentity {
id: string
role: string
}
/**
* I1/I2: an approver must be a human role outside the proposal's origination
* chain. Agents, the runtime, and any id appearing as originator are rejected.
*/
export function assertApproverIndependent(proposal: Proposal, approver: ApproverIdentity, humanRoles: string[]): void {
const chain = new Set([proposal.originator.agent, proposal.originator.task_id, ...proposal.lineage.tool_calls.map((t) => t.tool)])
if (chain.has(approver.id)) throw new Error(`approver ${approver.id} is in the origination chain (I1)`)
if (!humanRoles.includes(approver.role)) throw new Error(`role '${approver.role}' may not approve (I1/I2: AI cannot approve)`)
}
export class AuthorityService {
constructor(
readonly permits: Repository<ExecutionPermit> = new MemoryRepository(PermitSchema, (p) => p.id),
private readonly idGen: () => string = () => `permit-${Math.random().toString(36).slice(2, 10)}`,
) {}
/**
* Fresh-state check then permit. Denial reasons are exhaustive so the case
* view can show *why* an approval went stale.
*/
authorize(proposal: Proposal, basis: ApprovalBasis, ctx: FreshCheckContext): ExecutionPermit | StaleDenial {
const reasons: string[] = []
const ledgerVersion = ctx.ledger.read().version
if (basis.approvals.length === 0 && !basis.envelopeMatch?.within) {
reasons.push('no approval basis: neither human approval nor envelope auto-approval')
}
for (const a of basis.approvals) {
if (a.decision !== 'APPROVE') reasons.push(`approval ${a.id} is ${a.decision}`)
if (a.proposal_digest !== proposal.digest) reasons.push(`approval ${a.id} bound to digest ${a.proposal_digest.slice(0, 8)}…, proposal is ${proposal.digest.slice(0, 8)}…`)
if (ctx.now < a.validity.from || ctx.now > a.validity.to) reasons.push(`approval ${a.id} validity [${a.validity.from}, ${a.validity.to}] does not cover ${ctx.now}`)
if (a.evidence_versions.ledger_version !== ledgerVersion) reasons.push(`ledger version ${a.evidence_versions.ledger_version} at approval, now ${ledgerVersion}`)
if (a.evidence_versions.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${a.evidence_versions.policy_pack_version} at approval, now ${ctx.currentPolicyPackVersion}`)
}
if (basis.envelopeMatch?.within) {
if (basis.envelopeMatch.proposal_digest !== proposal.digest) reasons.push('envelope match bound to a different digest')
if (proposal.lineage.ledger_version !== ledgerVersion) reasons.push(`ledger version ${proposal.lineage.ledger_version} at auto-approval, now ${ledgerVersion}`)
if (proposal.lineage.policy_pack_version !== ctx.currentPolicyPackVersion) reasons.push(`policy pack ${proposal.lineage.policy_pack_version} at check, now ${ctx.currentPolicyPackVersion}`)
}
if (ctx.offlineResources.length > 0) reasons.push(`resources offline since approval: ${ctx.offlineResources.join(', ')}`)
if (reasons.length > 0) return { proposal_digest: proposal.digest, reasons, checked_at: ctx.now }
// Effect limits: narrowest of approval scopes; energy actually offered otherwise.
const limits: Record<string, string> = {}
for (const a of basis.approvals) for (const [k, v] of Object.entries(a.scope.limits)) {
limits[k] = limits[k] === undefined || new Decimal(v).lt(limits[k]!) ? v : limits[k]!
}
if (proposal.type === 'BID' && limits['max_energy_mwh'] === undefined) {
limits['max_energy_mwh'] = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0)).toString()
}
const issuedAt = new Date(ctx.now)
let expires = new Date(issuedAt.getTime() + ctx.permitTtlMs)
for (const a of basis.approvals) if (new Date(a.validity.to) < expires) expires = new Date(a.validity.to)
const permit: ExecutionPermit = {
id: this.idGen(),
proposal_digest: proposal.digest,
issued_at: issuedAt.toISOString(),
expires_at: expires.toISOString(),
effect_limits: limits,
revoked_at: null,
issuer: 'authority-service',
}
this.permits.put(permit)
return permit
}
revoke(permitId: string, reason: string, now: string): void {
const row = this.permits.get(permitId)
if (!row) throw new Error(`permit ${permitId} not found`)
if (row.value.revoked_at) return
this.permits.put({ ...row.value, revoked_at: now }, row.version)
void reason
}
/** Gateway-side validation (I4): digest match, in window, not revoked, on record. */
validate(permit: ExecutionPermit, proposal: Proposal, now: string): string[] {
const reasons: string[] = []
const stored = this.permits.get(permit.id)?.value
if (!stored) reasons.push(`permit ${permit.id} not on record`)
else if (stored.revoked_at) reasons.push(`permit ${permit.id} revoked at ${stored.revoked_at}`)
if (permit.proposal_digest !== proposal.digest) reasons.push('permit digest does not match proposal')
if (now < permit.issued_at || now >= permit.expires_at) reasons.push(`permit expired at ${permit.expires_at} (now ${now})`)
if (proposal.type === 'BID' && permit.effect_limits['max_energy_mwh'] !== undefined) {
const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
if (total.gt(permit.effect_limits['max_energy_mwh'])) reasons.push(`energy ${total.toString()} exceeds permit limit ${permit.effect_limits['max_energy_mwh']}`)
}
return reasons
}
}
export const isStaleDenial = (x: ExecutionPermit | StaleDenial): x is StaleDenial => 'reasons' in x

View File

@ -0,0 +1,180 @@
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),
})

View File

@ -0,0 +1,12 @@
import type { Proposal } from '@vpp/domain'
import { canonicalJson, contentRef } from './snapshot.js'
/**
* Proposal digest (docs/03 §1): sha256 over canonical payload + lineage. It is
* what approvals and permits bind to (I3/I4) — any change to a number or to
* its provenance yields a different digest and therefore a different approval.
*/
export const proposalDigest = (p: Pick<Proposal, 'payload' | 'lineage'>): string =>
contentRef(canonicalJson({ payload: p.payload, lineage: p.lineage }))
export const digestMatches = (p: Proposal): boolean => proposalDigest(p) === p.digest

View File

@ -0,0 +1,107 @@
import type { ApprovalLevel, Curve96, Envelope, EnvelopeMatch, Proposal } from '@vpp/domain'
import { Envelope as EnvelopeSchema } from '@vpp/domain'
import { Decimal } from './decimal.js'
import { MemoryRepository } from './relational.js'
import type { Repository } from './relational.js'
/**
* Envelope gate (docs/03 §3, P3 tiered autonomy). Human-approved envelopes
* define what may auto-approve; anything outside, or with no ACTIVE envelope,
* needs a human. Bound keys for BID (docs/03 §3, values OPEN-QUESTION B1):
* price_deviation_pct — |offer − baseline| / baseline ≤ pct, per interval
* max_energy_mwh — Σ quantities ≤ max
* The empty envelope set is the "everything manual" autonomy dial position.
*/
export interface EnvelopeContext {
now: string
/** Baseline the price bound is relative to (the P50 price forecast in lineage). */
priceBaseline: Curve96 | null
/** Level required when no envelope applies. OPEN-QUESTION B3. */
fallbackLevel?: ApprovalLevel
}
export class EnvelopeService {
constructor(readonly repo: Repository<Envelope> = new MemoryRepository(EnvelopeSchema, (e) => e.id)) {}
register(envelope: Envelope): void {
this.repo.put(envelope)
}
match(proposal: Proposal, ctx: EnvelopeContext): EnvelopeMatch {
const fallback = ctx.fallbackLevel ?? 'L2'
const candidates = this.repo
.list()
.map((r) => r.value)
.filter(
(e) =>
e.status === 'ACTIVE' &&
e.scope.proposal_type === proposal.type &&
e.scope.timescales.includes(proposal.timescale) &&
e.validity.from <= ctx.now &&
ctx.now <= e.validity.to,
)
if (candidates.length === 0) {
return {
proposal_digest: proposal.digest,
within: false,
envelope_id: null,
required_level: fallback,
reasons: [`no ACTIVE envelope covers ${proposal.type}/${proposal.timescale} at ${ctx.now}`],
checked_at: ctx.now,
}
}
const reasonsByEnvelope: string[] = []
for (const env of candidates) {
const reasons = this.checkBounds(proposal, env, ctx)
if (reasons.length === 0) {
return { proposal_digest: proposal.digest, within: true, envelope_id: env.id, required_level: env.approval.level, reasons: [], checked_at: ctx.now }
}
reasonsByEnvelope.push(...reasons.map((r) => `${env.id}: ${r}`))
}
return {
proposal_digest: proposal.digest,
within: false,
envelope_id: null,
required_level: fallback,
reasons: reasonsByEnvelope,
checked_at: ctx.now,
}
}
private checkBounds(proposal: Proposal, env: Envelope, ctx: EnvelopeContext): string[] {
if (proposal.type !== 'BID') return ['unsupported proposal type for envelope v1']
const reasons: string[] = []
const maxEnergy = env.bounds['max_energy_mwh']
if (maxEnergy !== undefined) {
const total = proposal.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
if (total.gt(maxEnergy)) reasons.push(`daily energy ${total.toString()} > max_energy_mwh ${maxEnergy}`)
}
const pct = env.bounds['price_deviation_pct']
if (pct !== undefined) {
if (!ctx.priceBaseline) {
reasons.push('price_deviation_pct bound requires a price baseline in lineage')
} else {
const tol = new Decimal(pct).div(100)
let worst: { i: number; dev: Decimal } | null = null
proposal.payload.prices_yuan_per_mwh.values.forEach((v, i) => {
const base = new Decimal(ctx.priceBaseline!.values[i]!)
const offer = new Decimal(v)
// A price-taker offer (0) never exceeds the baseline; only offers above baseline are risky.
if (offer.lte(base)) return
if (base.eq(0)) {
worst = { i, dev: new Decimal('Infinity' as unknown as number) }
return
}
const dev = offer.sub(base).div(base)
if (dev.gt(tol) && (!worst || dev.gt(worst.dev))) worst = { i, dev }
})
if (worst) {
const w = worst as { i: number; dev: Decimal }
reasons.push(`interval ${w.i}: offer deviates ${w.dev.mul(100).toFixed(2)}% above baseline, bound ${pct}%`)
}
}
}
return reasons
}
}

View File

@ -0,0 +1,104 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname } from 'node:path'
import type { EventEnvelope } from '@vpp/domain'
import { EventEnvelope as EventEnvelopeSchema } from '@vpp/domain'
/**
* Event store + bus (P5 event sourcing, ADR-0004 outbox). Append-only log of
* EventEnvelopes; consumers subscribe by type. Phase-1 backing is a JSONL
* file (survives restarts, trivially auditable); the Postgres outbox adapter
* implements the same interface without leaking storage specifics.
*/
export interface EventFilter {
event_type?: string
correlation_id?: string
}
export type EventHandler = (evt: EventEnvelope) => void | Promise<void>
export interface EventBus {
append(input: {
event_type: string
payload: unknown
correlation_id: string
causation_id?: string
schema_version?: string
}): EventEnvelope
list(filter?: EventFilter): EventEnvelope[]
subscribe(event_type: string, handler: EventHandler): () => void
}
export class MemoryEventBus implements EventBus {
protected readonly events: EventEnvelope[] = []
private readonly handlers = new Map<string, Set<EventHandler>>()
private seq = 0
constructor(
protected readonly clock: () => string = () => new Date().toISOString(),
protected readonly prefix = 'evt',
) {}
append(input: {
event_type: string
payload: unknown
correlation_id: string
causation_id?: string
schema_version?: string
}): EventEnvelope {
const evt: EventEnvelope = EventEnvelopeSchema.parse({
event_id: `${this.prefix}-${String(++this.seq).padStart(6, '0')}`,
event_type: input.event_type,
schema_version: input.schema_version ?? '1.0.0',
occurred_at: this.clock(),
causation_id: input.causation_id ?? input.correlation_id,
correlation_id: input.correlation_id,
payload: input.payload,
})
this.persist(evt)
this.events.push(evt)
for (const h of this.handlers.get(evt.event_type) ?? []) void h(evt)
for (const h of this.handlers.get('*') ?? []) void h(evt)
return evt
}
protected persist(_evt: EventEnvelope): void {}
list(filter: EventFilter = {}): EventEnvelope[] {
return this.events.filter(
(e) =>
(filter.event_type === undefined || e.event_type === filter.event_type) &&
(filter.correlation_id === undefined || e.correlation_id === filter.correlation_id),
)
}
subscribe(event_type: string, handler: EventHandler): () => void {
const set = this.handlers.get(event_type) ?? new Set()
set.add(handler)
this.handlers.set(event_type, set)
return () => set.delete(handler)
}
protected load(events: EventEnvelope[]): void {
this.events.push(...events)
this.seq = events.length
}
}
/** JSONL-backed bus: every appended event is fsync'd to disk before handlers run. */
export class FileEventBus extends MemoryEventBus {
constructor(
private readonly path: string,
clock?: () => string,
) {
super(clock, 'evt')
mkdirSync(dirname(path), { recursive: true })
if (existsSync(path)) {
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean)
this.load(lines.map((l) => EventEnvelopeSchema.parse(JSON.parse(l))))
}
}
protected override persist(evt: EventEnvelope): void {
appendFileSync(this.path, JSON.stringify(evt) + '\n')
}
}

View File

@ -0,0 +1,75 @@
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import type { BidExportFile, ExecutionPermit, ExecutionReceipt, Proposal } from '@vpp/domain'
import { BidExportFile as BidExportFileSchema, ExecutionReceipt as ReceiptSchema } from '@vpp/domain'
import type { AuthorityService } from './authority.js'
import { MemoryRepository } from './relational.js'
import type { Repository } from './relational.js'
export class GatewayRejected extends Error {
constructor(readonly reasons: string[]) {
super(`gateway rejected: ${reasons.join('; ')}`)
this.name = 'GatewayRejected'
}
}
/**
* Gateway port (docs/03 §2.2, docs/11 §1.1): accepts only (Proposal, Permit)
* pairs, validates through the authority service, and is idempotent by
* (digest, permit) — re-dispatch returns the persisted receipt (I5).
*/
export interface GatewayPort {
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt
}
/**
* M3 release path: bid file for manual upload to the trading platform — the
* degraded channel by design (docs/06, ROADMAP M3). The programmatic channel
* is phase 2 (D2) and will implement the same port.
*/
export class FileExportGateway implements GatewayPort {
constructor(
private readonly authority: AuthorityService,
private readonly exportDir: string,
readonly receipts: Repository<ExecutionReceipt> = new MemoryRepository(ReceiptSchema, (r) => r.idempotency_key),
) {
mkdirSync(exportDir, { recursive: true })
}
dispatch(proposal: Proposal, permit: ExecutionPermit, now: string): ExecutionReceipt {
const key = `${proposal.digest}:${permit.id}`
const existing = this.receipts.get(key)
if (existing) return existing.value
const reasons = this.authority.validate(permit, proposal, now)
if (reasons.length > 0) throw new GatewayRejected(reasons)
if (proposal.type !== 'BID') throw new GatewayRejected([`file export supports BID only, got ${proposal.type}`])
const file: BidExportFile = BidExportFileSchema.parse({
format_version: '1.0.0',
proposal_id: proposal.id,
proposal_digest: proposal.digest,
permit_id: permit.id,
proposal_type: proposal.type,
market_date: proposal.payload.market_date,
prices_yuan_per_mwh: proposal.payload.prices_yuan_per_mwh,
quantities_mwh: proposal.payload.quantities_mwh,
exported_at: now,
})
const name = `bid-${file.market_date}-${proposal.id}-${permit.id}.json`
const path = join(this.exportDir, name)
if (!existsSync(path)) writeFileSync(path, JSON.stringify(file, null, 2) + '\n', { flag: 'wx' })
const receipt: ExecutionReceipt = {
receipt_id: `rcpt-${proposal.id}-${permit.id}`,
proposal_digest: proposal.digest,
permit_id: permit.id,
channel: 'FILE_EXPORT',
idempotency_key: key,
artifact_ref: path,
accepted_at: now,
}
this.receipts.put(receipt)
return receipt
}
}

View File

@ -5,3 +5,13 @@ export * from './timeseries.js'
export * from './relational.js' export * from './relational.js'
export * from './ingest.js' export * from './ingest.js'
export { Decimal } from './decimal.js' export { Decimal } from './decimal.js'
export * from './digest.js'
export * from './policy.js'
export * from './envelope.js'
export * from './authority.js'
export * from './gateway.js'
export * from './events.js'
export * from './lineage.js'
export * from './simulation.js'
export * from './casedesk.js'
export * from './skillclient.js'

View File

@ -0,0 +1,120 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { BidPayload, BidProposalDraft, LineageRef, Proposal, ToolCallRef } from '@vpp/domain'
import { BidPayload as BidPayloadSchema, Curve96, DecimalString } from '@vpp/domain'
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) }
}

View File

@ -0,0 +1,210 @@
import type { LedgerView, Proposal, ValidationResult } from '@vpp/domain'
import { digestMatches } from './digest.js'
import type { LedgerService } from './ledger.js'
import type { SnapshotStore } from './snapshot.js'
import { Decimal } from './decimal.js'
/**
* Policy engine (docs/05 §4.1, docs/03 §2 规则校核): versioned, tested,
* executable rule packs. Rules are deterministic functions of the proposal
* and its evidence context; the pack version is frozen into lineage so any
* historical check is replayable (I7). Old packs stay in the registry.
*/
export interface RuleContext {
ledger: LedgerView
ledgerService: LedgerService
snapshots: SnapshotStore
now: string
}
export interface Violation {
rule_id: string
severity: 'REJECT' | 'WARN'
message: string
}
export interface Rule {
id: string
description: string
/** Knowledge-base entries this rule implements (explainability: rule → source). */
source_refs: string[]
check(proposal: Proposal, ctx: RuleContext): Violation[]
}
export interface PolicyPack {
id: string
version: string
effective: { from: string; to: string | null }
rules: Rule[]
}
export class PolicyEngine {
private readonly packs = new Map<string, PolicyPack>()
constructor(packs: PolicyPack[]) {
for (const p of packs) this.packs.set(`${p.id}@${p.version}`, p)
}
pack(id: string, version: string): PolicyPack {
const p = this.packs.get(`${id}@${version}`)
if (!p) throw new Error(`policy pack ${id}@${version} not registered`)
return p
}
/** Latest pack by version string order (versions are YYYY.MM, lexicographically ordered). */
current(id: string): PolicyPack {
const versions = [...this.packs.values()].filter((p) => p.id === id).sort((a, b) => (a.version < b.version ? 1 : -1))
if (versions.length === 0) throw new Error(`no policy pack registered for ${id}`)
return versions[0]!
}
check(proposal: Proposal, packRef: { id: string; version: string }, ctx: RuleContext): ValidationResult {
const pack = this.pack(packRef.id, packRef.version)
const violations: Violation[] = []
const evaluated: string[] = []
for (const rule of pack.rules) {
evaluated.push(rule.id)
violations.push(...rule.check(proposal, ctx))
}
return {
proposal_digest: proposal.digest,
policy_pack: { id: pack.id, version: pack.version },
ok: !violations.some((v) => v.severity === 'REJECT'),
violations,
rules_evaluated: evaluated,
checked_at: ctx.now,
}
}
}
// ---------------------------------------------------------------------------
// Hubei spot bidding pack v1. Numeric limits below are placeholders pending
// OPEN-QUESTION D5 (首批转为政策包的湖北规则清单) and A1/A4/A5 — they are named
// config so the values can be replaced without touching rule logic.
// ---------------------------------------------------------------------------
export interface HubeiBidPackConfig {
// OPEN-QUESTION D5: Hubei day-ahead price floor/cap for VPP offers.
priceFloorYuanPerMwh: string
priceCapYuanPerMwh: string
/** Agents permitted to originate each proposal type (I2: operate ≠ approve ≠ administer). */
originators: Record<string, string[]>
}
export const HUBEI_BID_PACK_PLACEHOLDER: HubeiBidPackConfig = {
priceFloorYuanPerMwh: '0', // OPEN-QUESTION D5
priceCapYuanPerMwh: '1500', // OPEN-QUESTION D5
originators: { BID: ['trading-agent', 'operator'] },
}
const reject = (rule_id: string, message: string): Violation => ({ rule_id, severity: 'REJECT', message })
export function hubeiSpotBiddingPack(version: string, cfg: HubeiBidPackConfig): PolicyPack {
return {
id: 'hubei-spot-bidding',
version,
effective: { from: '2026-01-01T00:00:00Z', to: null },
rules: [
{
id: 'digest-valid',
description: 'digest equals sha256(canonical payload + lineage)',
source_refs: ['docs/03 §1'],
check: (p) => (digestMatches(p) ? [] : [reject('digest-valid', 'digest does not match payload + lineage')]),
},
{
id: 'bid-format',
description: '96 intervals, curves dated on market_date',
source_refs: ['docs/11 §3.3', 'D5'],
check: (p) => {
if (p.type !== 'BID') return []
const out: Violation[] = []
for (const [name, c] of [['prices_yuan_per_mwh', p.payload.prices_yuan_per_mwh], ['quantities_mwh', p.payload.quantities_mwh]] as const) {
if (c.date !== p.payload.market_date) out.push(reject('bid-format', `${name} dated ${c.date}, market_date is ${p.payload.market_date}`))
if (c.values.length !== 96) out.push(reject('bid-format', `${name} has ${c.values.length} values`))
}
if (p.timescale !== 'DAY_AHEAD') out.push(reject('bid-format', `BID must be DAY_AHEAD, got ${p.timescale}`))
return out
},
},
{
id: 'price-limits',
description: 'every offer price within the market floor/cap',
source_refs: ['D5'],
check: (p) => {
if (p.type !== 'BID') return []
const floor = new Decimal(cfg.priceFloorYuanPerMwh)
const cap = new Decimal(cfg.priceCapYuanPerMwh)
const out: Violation[] = []
p.payload.prices_yuan_per_mwh.values.forEach((v, i) => {
const d = new Decimal(v)
if (d.lt(floor) || d.gt(cap)) out.push(reject('price-limits', `interval ${i}: price ${v} outside [${floor}, ${cap}]`))
})
return out.slice(0, 5)
},
},
{
id: 'quantity-non-negative',
description: 'offer quantities are non-negative',
source_refs: ['D5'],
check: (p) =>
p.type === 'BID' && p.payload.quantities_mwh.values.some((v) => new Decimal(v).lt(0))
? [reject('quantity-non-negative', 'negative quantity in bid')]
: [],
},
{
id: 'ledger-consistency',
description: 'lineage ledger version is current and daily energy is inside the cascade band (P7)',
source_refs: ['docs/00 §4', 'A5'],
check: (p, ctx) => {
if (p.type !== 'BID') return []
const out: Violation[] = []
if (p.lineage.ledger_version !== ctx.ledger.version) {
out.push(reject('ledger-consistency', `lineage ledger_version ${p.lineage.ledger_version} ≠ current ${ctx.ledger.version}`))
}
try {
const b = ctx.ledgerService.dayAheadBounds(p.payload.market_date)
const total = p.payload.quantities_mwh.values.reduce((s, v) => s.add(new Decimal(v)), new Decimal(0))
if (total.lt(b.daily_energy_min_mwh) || total.gt(b.daily_energy_max_mwh)) {
out.push(reject('ledger-consistency', `daily energy ${total.toString()} outside [${b.daily_energy_min_mwh}, ${b.daily_energy_max_mwh}]`))
}
} catch (e) {
out.push(reject('ledger-consistency', (e as Error).message))
}
return out
},
},
{
id: 'lineage-integrity',
description: 'every payload number is found verbatim in a referenced tool output (P2, machine-checkable)',
source_refs: ['docs/03 §1', 'docs/01 P2'],
check: (p, ctx) => {
if (p.type !== 'BID') return []
if (p.lineage.tool_calls.length === 0) return [reject('lineage-integrity', 'no tool calls in lineage')]
const outputs: Record<string, unknown>[] = []
for (const tc of p.lineage.tool_calls) {
const out = ctx.snapshots.get(tc.outputs_ref)
if (out === undefined) return [reject('lineage-integrity', `tool call ${tc.tool_call_id}: output snapshot ${tc.outputs_ref.slice(0, 8)}… missing`)]
if (out && typeof out === 'object') outputs.push(out as Record<string, unknown>)
}
const found = (pred: (o: Record<string, unknown>) => boolean) => outputs.some(pred)
const eqCurve = (a: unknown, b: { values: string[] }) =>
!!a && typeof a === 'object' && JSON.stringify((a as { values?: unknown }).values) === JSON.stringify(b.values)
const out: Violation[] = []
if (!found((o) => eqCurve(o['prices_yuan_per_mwh'], p.payload.prices_yuan_per_mwh))) out.push(reject('lineage-integrity', 'prices_yuan_per_mwh not traceable to a tool output'))
if (!found((o) => eqCurve(o['quantities_mwh'], p.payload.quantities_mwh))) out.push(reject('lineage-integrity', 'quantities_mwh not traceable to a tool output'))
if (!found((o) => o['expected_revenue_yuan'] === p.payload.expected_revenue_yuan)) out.push(reject('lineage-integrity', 'expected_revenue_yuan not traceable to a tool output'))
return out
},
},
{
id: 'originator-permission',
description: 'originating agent is allowed to originate this proposal type (I2)',
source_refs: ['docs/03 §2', 'docs/06 §4'],
check: (p) =>
(cfg.originators[p.type] ?? []).includes(p.originator.agent)
? []
: [reject('originator-permission', `${p.originator.agent} may not originate ${p.type}`)],
},
],
}
}

View File

@ -1,3 +1,5 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import type { z } from 'zod' import type { z } from 'zod'
import { ResourceProfile as ResourceProfileSchema } from '@vpp/domain' import { ResourceProfile as ResourceProfileSchema } from '@vpp/domain'
import type { ResourceProfile } from '@vpp/domain' import type { ResourceProfile } from '@vpp/domain'
@ -53,6 +55,11 @@ export class MemoryRepository<T> implements Repository<T> {
return [...this.rows.keys()].sort().map((k) => this.get(k)!) return [...this.rows.keys()].sort().map((k) => this.get(k)!)
} }
/** Load a persisted row verbatim (versions preserved). */
protected restore(row: Row<T>): void {
this.rows.set(row.key, row)
}
put(value: T, expectedVersion?: number): Row<T> { put(value: T, expectedVersion?: number): Row<T> {
const parsed = this.schema.parse(value) const parsed = this.schema.parse(value)
const key = this.keyOf(parsed) const key = this.keyOf(parsed)
@ -76,3 +83,30 @@ export type ResourceRegistry = Repository<ResourceProfile>
export const createResourceRegistry = (clock?: () => string): ResourceRegistry => export const createResourceRegistry = (clock?: () => string): ResourceRegistry =>
new MemoryRepository(ResourceProfileSchema, (r) => r.resource_id, clock) new MemoryRepository(ResourceProfileSchema, (r) => r.resource_id, clock)
/**
* JSON-file-backed repository: full rewrite on every put (row counts in
* phase 1 are tiny: cases, envelopes, permits). Enough for "a suspended
* approval survives process restart" until the Postgres adapter (M3+).
*/
export class FsRepository<T> extends MemoryRepository<T> {
constructor(
private readonly path: string,
schema: z.ZodType<T>,
keyOf: (value: T) => string,
clock?: () => string,
) {
super(schema, keyOf, clock)
mkdirSync(dirname(path), { recursive: true })
if (existsSync(path)) {
const rows = JSON.parse(readFileSync(path, 'utf8')) as Row<T>[]
for (const r of rows) this.restore({ ...r, value: schema.parse(r.value) })
}
}
override put(value: T, expectedVersion?: number): Row<T> {
const row = super.put(value, expectedVersion)
writeFileSync(this.path, JSON.stringify(this.list(), null, 1) + '\n')
return row
}
}

View File

@ -0,0 +1,105 @@
import type { ForecastBundle, Proposal, SimulationResult } from '@vpp/domain'
import { Decimal } from './decimal.js'
/**
* Simulation port (docs/03 §2 仿真验证; docs/05 §2.3 收益情景回测). Bids are
* stress-tested against many price scenarios drawn around the forecast band;
* any alert escalates to a human — one vote, no exceptions.
*
* v1 is deterministic (seeded) and in-process. Scenario paths are built from
* the P10/P50/P90 band by a seeded uniform draw per interval, interpolated
* piecewise-linearly, with per-day correlated shocks so extreme days exist.
*/
export interface SimulationPort {
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult
}
export interface SimulationContext {
now: string
priceForecast: ForecastBundle | null
marginalCostYuanPerMwh: string
/**
* Alert when the 5th-percentile scenario revenue net of cost falls below
* −budget. OPEN-QUESTION B5 (单日亏损预算) — placeholder.
*/
worstCaseLossBudgetYuan: string
scenarioCount?: number
seed?: number
}
/** Small deterministic PRNG (mulberry32) — enough for scenario sampling. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export class RevenueScenarioSimulator implements SimulationPort {
static readonly NAME = 'revenue-scenarios'
static readonly VERSION = '1.0.0'
simulate(proposal: Proposal, ctx: SimulationContext): SimulationResult {
const seed = ctx.seed ?? 20260315
const n = ctx.scenarioCount ?? 1000
const alerts: string[] = []
const base = { proposal_digest: proposal.digest, kind: 'REVENUE_SCENARIOS' as const, simulator: { name: RevenueScenarioSimulator.NAME, version: RevenueScenarioSimulator.VERSION, seed }, simulated_at: ctx.now }
if (proposal.type !== 'BID') {
return { ...base, alert: true, alerts: ['revenue simulation only supports BID'], metrics: {}, scenario_count: 0 }
}
if (!ctx.priceForecast) {
return { ...base, alert: true, alerts: ['no price forecast in lineage to simulate against'], metrics: {}, scenario_count: 0 }
}
const p10 = ctx.priceForecast.quantiles.p10.values.map(Number)
const p50 = ctx.priceForecast.quantiles.p50.values.map(Number)
const p90 = ctx.priceForecast.quantiles.p90.values.map(Number)
const offers = proposal.payload.prices_yuan_per_mwh.values.map(Number)
const qty = proposal.payload.quantities_mwh.values.map(Number)
const cost = Number(ctx.marginalCostYuanPerMwh)
const rand = mulberry32(seed)
const revenues: number[] = []
for (let s = 0; s < n; s++) {
const dayShock = rand() // correlated across intervals: a "high day" or "low day"
let rev = 0
for (let t = 0; t < 96; t++) {
const u = 0.5 * dayShock + 0.5 * rand()
// Map u∈[0,1] onto the band: 0→below P10, 0.5→P50, 1→above P90 (tails extend 50%).
let price: number
if (u < 0.1) price = p10[t]! - (0.1 - u) / 0.1 * 0.5 * (p50[t]! - p10[t]!)
else if (u < 0.5) price = p10[t]! + (u - 0.1) / 0.4 * (p50[t]! - p10[t]!)
else if (u < 0.9) price = p50[t]! + (u - 0.5) / 0.4 * (p90[t]! - p50[t]!)
else price = p90[t]! + (u - 0.9) / 0.1 * 0.5 * (p90[t]! - p50[t]!)
if (offers[t]! <= price) rev += (price - cost) * qty[t]!
}
revenues.push(rev)
}
revenues.sort((a, b) => a - b)
const pct = (p: number) => revenues[Math.min(n - 1, Math.floor(p * n))]!
const p05 = pct(0.05)
const worstLoss = Math.max(0, -revenues[0]!)
const budget = Number(ctx.worstCaseLossBudgetYuan)
if (p05 < -budget) alerts.push(`P05 scenario revenue ${p05.toFixed(2)} below loss budget −${budget}`)
if (qty.every((q) => q === 0)) alerts.push('bid offers zero energy in every interval')
const money = (x: number) => new Decimal(x.toFixed(2)).toFixed(2)
return {
...base,
alert: alerts.length > 0,
alerts,
metrics: {
revenue_p05_yuan: money(p05),
revenue_p50_yuan: money(pct(0.5)),
revenue_p95_yuan: money(pct(0.95)),
worst_loss_yuan: money(worstLoss),
},
scenario_count: n,
}
}
}

View File

@ -0,0 +1,293 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { BidProposalDraft, Proposal as ProposalSchema } from '@vpp/domain'
import { AuthorityService, assertApproverIndependent, isStaleDenial } from '../src/authority.js'
import { CaseDeskService } from '../src/casedesk.js'
import { proposalDigest } from '../src/digest.js'
import { EnvelopeService } from '../src/envelope.js'
import { FileEventBus, MemoryEventBus } from '../src/events.js'
import { FileExportGateway, GatewayRejected } from '../src/gateway.js'
import { LineageRefError, assembleBidProposal } from '../src/lineage.js'
import { FsRepository } from '../src/relational.js'
import { RevenueScenarioSimulator } from '../src/simulation.js'
import { MemorySnapshotStore } from '../src/snapshot.js'
import { DATE, NOW, PACK_VERSION, approvalFor, curve, envelope, priceForecast, world } from './helpers.js'
const HUMAN_ROLES = ['senior-trader', 'ops-lead', 'risk-officer']
const freshCtx = (w: ReturnType<typeof world>, over: Record<string, unknown> = {}) => ({
now: NOW, ledger: w.ledger, currentPolicyPackVersion: PACK_VERSION, offlineResources: [], permitTtlMs: 3_600_000, ...over,
})
describe('policy engine: hubei-spot-bidding pack (each rule has a pass and a fail case)', () => {
it('passes a well-formed, traceable, in-band bid', () => {
const w = world()
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
expect(r.ok, JSON.stringify(r.violations)).toBe(true)
expect(r.rules_evaluated).toContain('lineage-integrity')
expect(r.policy_pack.version).toBe(PACK_VERSION)
})
it('digest-valid: a tampered payload no longer matches its digest', () => {
const w = world()
const tampered = { ...w.proposal, payload: { ...w.proposal.payload, expected_revenue_yuan: '999.00' } }
const r = w.engine.check(tampered, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
expect(r.violations.map((v) => v.rule_id)).toContain('digest-valid')
})
it('price-limits: offer above the cap is rejected', () => {
const w = world({ price: '1500.01' })
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
expect(r.ok).toBe(false)
expect(r.violations[0]!.rule_id).toBe('price-limits')
})
it('ledger-consistency: energy outside the cascade band, or a stale ledger version, is rejected', () => {
const over = world({ qty: '14.0' }) // 1344 MWh > 1260
let r = over.engine.check(over.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, over.ruleCtx())
expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /outside/.test(v.message))).toBe(true)
const w = world()
w.ledger.append({ id: 'x', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
expect(r.violations.some((v) => v.rule_id === 'ledger-consistency' && /ledger_version/.test(v.message))).toBe(true)
})
it('lineage-integrity: a number not present in any referenced tool output is rejected (P2)', () => {
const w = world()
const forged = { ...w.proposal, payload: { ...w.proposal.payload, quantities_mwh: curve('12.6') } }
forged.digest = proposalDigest(forged) // even a re-digested forgery fails: the number has no provenance
const r = w.engine.check(forged, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
expect(r.violations.map((v) => v.rule_id)).toContain('lineage-integrity')
expect(r.violations.map((v) => v.rule_id)).not.toContain('digest-valid')
})
it('originator-permission: an agent outside the allowlist may not originate a BID (I2)', () => {
const w = world({ agent: 'load-control-agent' })
const r = w.engine.check(w.proposal, { id: 'hubei-spot-bidding', version: PACK_VERSION }, w.ruleCtx())
expect(r.violations.map((v) => v.rule_id)).toContain('originator-permission')
})
it('old pack versions stay registered for replay (I7)', () => {
const w = world()
expect(w.engine.current('hubei-spot-bidding').version).toBe(PACK_VERSION)
expect(() => w.engine.pack('hubei-spot-bidding', '2025.01')).toThrow(/not registered/)
})
})
describe('P2 assembler: numbers enter a proposal only by lineage reference', () => {
it('rejects a draft carrying a literal number at the schema boundary', () => {
const draft = { market_date: DATE, prices_ref: { tool_call_id: 'tc-1', path: 'p' }, quantities_ref: { tool_call_id: 'tc-1', path: 'q' }, expected_revenue_ref: '510600.00', rationale: 'x' }
expect(BidProposalDraft.safeParse(draft).success).toBe(false)
})
it('rejects a reference to a tool call that is not in lineage', () => {
const w = world()
expect(() =>
assembleBidProposal(
{ market_date: DATE, prices_ref: { tool_call_id: 'tc-99', path: 'prices_yuan_per_mwh' }, quantities_ref: { tool_call_id: 'tc-99', path: 'quantities_mwh' }, expected_revenue_ref: { tool_call_id: 'tc-99', path: 'expected_revenue_yuan' }, rationale: 'x' },
{ id: 'p', originator: w.proposal.originator, lineage: w.proposal.lineage, ledger_version: 1, policy_pack_version: PACK_VERSION, snapshots: w.snapshots, now: NOW },
),
).toThrow(LineageRefError)
})
it('assembled proposal validates against the domain schema and its digest is reproducible', () => {
const w = world()
expect(ProposalSchema.safeParse(w.proposal).success).toBe(true)
expect(proposalDigest(w.proposal)).toBe(w.proposal.digest)
expect(w.proposal.lineage.tool_calls.map((t) => t.tool)).toEqual(['price-forecast', 'bid-optimization-milp'])
})
})
describe('envelope gate (P3)', () => {
it('empty envelope set → everything goes to a human (autonomy dial at zero)', () => {
const w = world()
const m = new EnvelopeService().match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 })
expect(m.within).toBe(false)
expect(m.envelope_id).toBeNull()
expect(m.required_level).toBe('L2')
})
it('auto-approves inside bounds and reports which envelope', () => {
const w = world()
const svc = new EnvelopeService()
svc.register(envelope({ price_deviation_pct: '5.0', max_energy_mwh: '1500.0' }))
const m = svc.match(w.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 })
expect(m.within).toBe(true)
expect(m.envelope_id).toBe('env-bid-001')
})
it('exceeding a bound, an expired window, or SUSPENDED status all fall out of the envelope', () => {
const w = world()
const outOfEnergy = new EnvelopeService(); outOfEnergy.register(envelope({ max_energy_mwh: '1000.0' }))
expect(outOfEnergy.match(w.proposal, { now: NOW, priceBaseline: null }).reasons[0]).toMatch(/max_energy_mwh/)
const expired = new EnvelopeService(); expired.register(envelope({ max_energy_mwh: '1500.0' }, { validity: { from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z' } }))
expect(expired.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
const suspended = new EnvelopeService(); suspended.register(envelope({ max_energy_mwh: '1500.0' }, { status: 'SUSPENDED' }))
expect(suspended.match(w.proposal, { now: NOW, priceBaseline: null }).within).toBe(false)
const priced = world({ price: '450.00' }) // 5.8% above 425.50 baseline
const tight = new EnvelopeService(); tight.register(envelope({ price_deviation_pct: '5.0' }))
expect(tight.match(priced.proposal, { now: NOW, priceBaseline: priceForecast().quantiles.p50 }).reasons[0]).toMatch(/deviates/)
})
})
describe('approval identity (I1/I2)', () => {
it('rejects the originating agent, any tool in the chain, and non-human roles', () => {
const w = world()
expect(() => assertApproverIndependent(w.proposal, { id: 'trading-agent', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/)
expect(() => assertApproverIndependent(w.proposal, { id: 'bid-optimization-milp', role: 'senior-trader' }, HUMAN_ROLES)).toThrow(/origination chain/)
expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'agent' }, HUMAN_ROLES)).toThrow(/AI cannot approve/)
expect(() => assertApproverIndependent(w.proposal, { id: 'user-x', role: 'senior-trader' }, HUMAN_ROLES)).not.toThrow()
})
})
describe('authority: fresh check, permits, revocation (I3/I4)', () => {
it('issues a permit bound to the digest with the narrowest effect limits', () => {
const w = world()
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1, { scope: { effect_type: 'BID', limits: { max_energy_mwh: '1250.0' } } })], envelopeMatch: null }, freshCtx(w))
expect(isStaleDenial(permit)).toBe(false)
if (isStaleDenial(permit)) throw new Error()
expect(permit.proposal_digest).toBe(w.proposal.digest)
expect(permit.effect_limits['max_energy_mwh']).toBe('1250.0')
expect(permit.expires_at).toBe('2026-03-14T09:00:00.000Z')
})
it('goes STALE when the digest, window, ledger, policy pack or resources changed since approval (I3)', () => {
const w = world()
const a = approvalFor(w.proposal, 1)
const stale = (basis: Parameters<AuthorityService['authorize']>[1], ctx = freshCtx(w)) => {
const r = w.authority.authorize(w.proposal, basis, ctx)
if (!isStaleDenial(r)) throw new Error('expected stale')
return r.reasons.join(' | ')
}
expect(stale({ approvals: [{ ...a, proposal_digest: 'e'.repeat(64) }], envelopeMatch: null })).toMatch(/digest/)
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { now: '2026-03-15T10:00:00Z' }))).toMatch(/validity/)
expect(stale({ approvals: [{ ...a, evidence_versions: { ...a.evidence_versions, ledger_version: 0 } }], envelopeMatch: null })).toMatch(/ledger version/)
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { currentPolicyPackVersion: '2026.04' }))).toMatch(/policy pack/)
expect(stale({ approvals: [a], envelopeMatch: null }, freshCtx(w, { offlineResources: ['res-storage-01'] }))).toMatch(/offline/)
expect(stale({ approvals: [{ ...a, decision: 'REJECT' }], envelopeMatch: null })).toMatch(/REJECT/)
expect(stale({ approvals: [], envelopeMatch: null })).toMatch(/no approval basis/)
})
it('an auto-approval is a valid basis only while its evidence is current', () => {
const w = world()
const match = { proposal_digest: w.proposal.digest, within: true, envelope_id: 'env-bid-001', required_level: 'L1' as const, reasons: [], checked_at: NOW }
expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(false)
w.ledger.append({ id: 'y', timescale: 'MONTHLY', period: '2026-04', kind: 'CONTRACT', energy_mwh: '1.0', curve: null, source_ref: 'c', expected_version: 1 })
expect(isStaleDenial(w.authority.authorize(w.proposal, { approvals: [], envelopeMatch: match }, freshCtx(w)))).toBe(true)
})
it('validate: expiry, revocation, digest mismatch and unknown permits are all rejected (I4)', () => {
const w = world()
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
if (isStaleDenial(permit)) throw new Error()
expect(w.authority.validate(permit, w.proposal, NOW)).toEqual([])
expect(w.authority.validate(permit, w.proposal, '2026-03-14T09:00:00.000Z')[0]).toMatch(/expired/)
expect(w.authority.validate({ ...permit, proposal_digest: 'e'.repeat(64) }, w.proposal, NOW)[0]).toMatch(/digest/)
expect(w.authority.validate({ ...permit, id: 'permit-forged' }, w.proposal, NOW)[0]).toMatch(/not on record/)
w.authority.revoke(permit.id, 'storage offline', '2026-03-14T08:30:00Z')
expect(w.authority.validate(permit, w.proposal, NOW)[0]).toMatch(/revoked/)
})
})
describe('gateway: only (Proposal, Permit) pairs; idempotent receipts (I4/I5)', () => {
it('exports a bid file once and returns the same receipt on re-dispatch', () => {
const w = world()
const dir = mkdtempSync(join(tmpdir(), 'vpp-export-'))
const gw = new FileExportGateway(w.authority, dir)
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
if (isStaleDenial(permit)) throw new Error()
const r1 = gw.dispatch(w.proposal, permit, NOW)
const r2 = gw.dispatch(w.proposal, permit, '2026-03-14T08:05:00Z')
expect(r2).toEqual(r1)
expect(gw.receipts.list()).toHaveLength(1)
const file = JSON.parse(readFileSync(r1.artifact_ref, 'utf8'))
expect(file.proposal_digest).toBe(w.proposal.digest)
expect(file.quantities_mwh.values[0]).toBe('12.5')
})
it('refuses a late release, a revoked permit, and a bare proposal with a forged permit', () => {
const w = world()
const gw = new FileExportGateway(w.authority, mkdtempSync(join(tmpdir(), 'vpp-export-')))
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
if (isStaleDenial(permit)) throw new Error()
expect(() => gw.dispatch(w.proposal, permit, '2026-03-15T09:00:01Z')).toThrow(GatewayRejected)
expect(() => gw.dispatch(w.proposal, { ...permit, id: 'permit-forged' }, NOW)).toThrow(/not on record/)
w.authority.revoke(permit.id, 'kill switch', NOW)
expect(() => gw.dispatch(w.proposal, permit, NOW)).toThrow(/revoked/)
expect(gw.receipts.list()).toHaveLength(0)
})
})
describe('revenue scenario simulation', () => {
it('is deterministic for a seed and does not alert on a sane price-taker bid', () => {
const w = world()
const sim = new RevenueScenarioSimulator()
const ctx = { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '100000' }
const a = sim.simulate(w.proposal, ctx)
const b = sim.simulate(w.proposal, ctx)
expect(a).toEqual(b)
expect(a.alert).toBe(false)
expect(a.scenario_count).toBe(1000)
expect(Number(a.metrics['revenue_p05_yuan'])).toBeLessThan(Number(a.metrics['revenue_p95_yuan']))
})
it('alerts when the P05 scenario breaches the loss budget, and when there is no forecast', () => {
const w = world()
const sim = new RevenueScenarioSimulator()
const costly = sim.simulate(w.proposal, { now: NOW, priceForecast: priceForecast(), marginalCostYuanPerMwh: '600', worstCaseLossBudgetYuan: '1000' })
expect(costly.alert).toBe(true)
expect(costly.alerts[0]).toMatch(/loss budget/)
expect(sim.simulate(w.proposal, { now: NOW, priceForecast: null, marginalCostYuanPerMwh: '0', worstCaseLossBudgetYuan: '1' }).alerts[0]).toMatch(/no price forecast/)
})
})
describe('case desk projections', () => {
it('opens a case, tracks proposal/approval/permit refs, and expands lineage to tool IO', () => {
const w = world()
const desk = new CaseDeskService({ authority: w.authority, snapshots: w.snapshots, events: w.events, clock: () => NOW })
const c = desk.open({ id: 'case-1', kind: 'DAY_AHEAD_BID', objective: 'D bid', owner: 'user-trader-01', deadline: '2026-03-14T09:00:00Z' })
desk.recordProposal(c.id, w.proposal)
desk.requestApproval({ proposal_id: w.proposal.id, proposal_digest: w.proposal.digest, case_id: c.id, run_id: 'run-1', required_level: 'L2', reasons: ['no envelope'], since: NOW })
expect(desk.inbox().map((p) => p.run_id)).toEqual(['run-1'])
expect(desk.read(c.id).case.status).toBe('AWAITING_APPROVAL')
desk.recordApproval(c.id, approvalFor(w.proposal, 1))
const permit = w.authority.authorize(w.proposal, { approvals: [approvalFor(w.proposal, 1)], envelopeMatch: null }, freshCtx(w))
if (isStaleDenial(permit)) throw new Error()
desk.recordPermit(c.id, permit)
const view = desk.read(c.id)
expect(view.approvals[0]!.approver.id).toBe('user-trader-01')
expect(view.permits[0]!.id).toBe('permit-001')
expect(view.events.map((e) => e.event_type)).toEqual(['CaseOpened', 'ProposalStatusChanged', 'ApprovalRequested', 'HumanDecision'])
const lin = desk.expandLineage(w.proposal.id)
expect(lin.tool_calls[1]!.tool).toBe('bid-optimization-milp')
expect((lin.tool_calls[1]!.outputs as { expected_revenue_yuan: string }).expected_revenue_yuan).toBe('510600.00')
})
})
describe('persistence for restart survival', () => {
it('FsRepository and FileEventBus reload their state in a new instance', () => {
const dir = mkdtempSync(join(tmpdir(), 'vpp-persist-'))
const w = world()
const repo = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id)
repo.put(w.proposal)
repo.put({ ...w.proposal, status: 'PENDING_HUMAN' }, 1)
const reloaded = new FsRepository(join(dir, 'proposals.json'), ProposalSchema, (p) => p.id)
expect(reloaded.get('prop-001')?.version).toBe(2)
expect(reloaded.get('prop-001')?.value.status).toBe('PENDING_HUMAN')
const bus = new FileEventBus(join(dir, 'events.jsonl'), () => NOW)
bus.append({ event_type: 'A', payload: { n: 1 }, correlation_id: 'c1' })
bus.append({ event_type: 'B', payload: { n: 2 }, correlation_id: 'c1', causation_id: 'evt-000001' })
const again = new FileEventBus(join(dir, 'events.jsonl'), () => NOW)
expect(again.list().map((e) => e.event_type)).toEqual(['A', 'B'])
expect(again.append({ event_type: 'C', payload: {}, correlation_id: 'c2' }).event_id).toBe('evt-000003')
expect(new MemoryEventBus().list()).toEqual([])
})
})

View File

@ -0,0 +1,99 @@
import type { Approval, Curve96, Envelope, ForecastBundle, Proposal } from '@vpp/domain'
import { AuthorityService } from '../src/authority.js'
import { MemoryEventBus } from '../src/events.js'
import { LedgerService } from '../src/ledger.js'
import { LineageRecorder, assembleBidProposal, withLineage } from '../src/lineage.js'
import { HUBEI_BID_PACK_PLACEHOLDER, PolicyEngine, hubeiSpotBiddingPack } from '../src/policy.js'
import { MemorySnapshotStore } from '../src/snapshot.js'
export const NOW = '2026-03-14T08:00:00Z'
export const DATE = '2026-03-15'
export const PACK_VERSION = '2026.03'
export const curve = (v: string | string[], date = DATE): Curve96 => ({
interval_minutes: 15,
date,
values: Array.isArray(v) ? v : Array.from({ length: 96 }, () => v),
})
export const priceForecast = (p50 = '425.50'): ForecastBundle => ({
id: 'fc-price',
kind: 'PRICE',
market_date: DATE,
unit: 'yuan_per_mwh',
quantiles: { p10: curve('360.00'), p50: curve(p50), p90: curve('490.00') },
model: { name: 'price-forecast', version: '1.0.0' },
features_snapshot_ref: 'a'.repeat(64),
generated_at: NOW,
})
/** A consistent little world: ledger with a monthly contract, a recorded MILP output, a proposal built from refs. */
export function world(opts: { qty?: string; price?: string; agent?: string } = {}) {
const snapshots = new MemorySnapshotStore()
const ledger = new LedgerService({ daMonthlyDeviationBand: '0.05', clock: () => NOW })
ledger.append({
id: 'contract-2026-03', timescale: 'MONTHLY', period: '2026-03', kind: 'CONTRACT',
energy_mwh: '37200.0', curve: null, source_ref: 'contract-2026-03-001', expected_version: 0,
}) // 1200 MWh/day → band [1140, 1260]
const engine = new PolicyEngine([hubeiSpotBiddingPack(PACK_VERSION, HUBEI_BID_PACK_PLACEHOLDER)])
const lineage = new LineageRecorder()
const events = new MemoryEventBus(() => NOW)
const authority = new AuthorityService(undefined, () => 'permit-001')
let n = 0
const milp = withLineage(lineage, snapshots, { tool: 'bid-optimization-milp', version: '1.0.0', idGen: () => `tc-${++n}` }, (_input: unknown) => ({
market_date: DATE,
prices_yuan_per_mwh: curve(opts.price ?? '0.00'),
quantities_mwh: curve(opts.qty ?? '12.5'), // 12.5 × 96 = 1200 MWh
expected_revenue_yuan: '510600.00',
}))
const forecastTool = withLineage(lineage, snapshots, { tool: 'price-forecast', version: '1.0.0', idGen: () => `tc-${++n}` }, (_i: unknown) => priceForecast())
const proposal = lineage.run(() => {
forecastTool({ kind: 'PRICE' })
const { call } = milp({ market_date: DATE })
return assembleBidProposal(
{
market_date: DATE,
prices_ref: { tool_call_id: call.tool_call_id, path: 'prices_yuan_per_mwh' },
quantities_ref: { tool_call_id: call.tool_call_id, path: 'quantities_mwh' },
expected_revenue_ref: { tool_call_id: call.tool_call_id, path: 'expected_revenue_yuan' },
rationale: 'test',
},
{
id: 'prop-001',
originator: { agent: opts.agent ?? 'trading-agent', trigger: 'SCHEDULED', task_id: 'task-001' },
lineage: lineage.current(),
ledger_version: ledger.read().version,
policy_pack_version: PACK_VERSION,
snapshots,
now: NOW,
},
)
})
const ruleCtx = () => ({ ledger: ledger.read(), ledgerService: ledger, snapshots, now: NOW })
return { snapshots, ledger, engine, lineage, events, authority, proposal, ruleCtx }
}
export const envelope = (bounds: Record<string, string>, over: Partial<Envelope> = {}): Envelope => ({
id: 'env-bid-001',
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
bounds,
validity: { from: '2026-03-01T00:00:00Z', to: '2026-03-31T23:59:59Z' },
approval: { level: 'L1', approved_by: ['user-ops-lead'] },
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
status: 'ACTIVE',
...over,
})
export const approvalFor = (p: Proposal, ledgerVersion: number, over: Partial<Approval> = {}): Approval => ({
id: 'appr-001',
proposal_digest: p.digest,
decision: 'APPROVE',
approver: { id: 'user-trader-01', role: 'senior-trader' },
scope: { effect_type: 'BID', limits: {} },
validity: { from: NOW, to: '2026-03-15T09:00:00Z' },
evidence_versions: { policy_pack_version: PACK_VERSION, ledger_version: ledgerVersion, data_snapshot_refs: [] },
decided_at: NOW,
...over,
})

View File

@ -0,0 +1,53 @@
# generated by datamodel-codegen:
# filename: bid_export_file.json
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
class ProposalType(StrEnum):
BID = 'BID'
INVITATION = 'INVITATION'
CONTROL_PLAN = 'CONTROL_PLAN'
DISPATCH_PLAN = 'DISPATCH_PLAN'
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
root: constr(pattern=r'^-?\d+(\.\d+)?$')
class PricesYuanPerMwh(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
interval_minutes: Literal[15]
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
values: list[Value] = Field(..., max_length=96, min_length=96)
class QuantitiesMwh(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
interval_minutes: Literal[15]
date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
values: list[Value] = Field(..., max_length=96, min_length=96)
class BidExportFile(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
format_version: Literal['1.0.0']
proposal_id: constr(min_length=1)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
permit_id: constr(min_length=1)
proposal_type: ProposalType
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
prices_yuan_per_mwh: PricesYuanPerMwh
quantities_mwh: QuantitiesMwh
exported_at: AwareDatetime

View File

@ -0,0 +1,41 @@
# generated by datamodel-codegen:
# filename: bid_proposal_draft.json
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, constr
class PricesRef(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
tool_call_id: constr(min_length=1)
path: constr(min_length=1)
class QuantitiesRef(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
tool_call_id: constr(min_length=1)
path: constr(min_length=1)
class ExpectedRevenueRef(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
tool_call_id: constr(min_length=1)
path: constr(min_length=1)
class BidProposalDraft(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
prices_ref: PricesRef
quantities_ref: QuantitiesRef
expected_revenue_ref: ExpectedRevenueRef
rationale: constr(min_length=1)

View File

@ -0,0 +1,26 @@
# generated by datamodel-codegen:
# filename: envelope_match.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class RequiredLevel(StrEnum):
L1 = 'L1'
L2 = 'L2'
L3 = 'L3'
class EnvelopeMatch(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
within: bool
envelope_id: constr(min_length=1) | None
required_level: RequiredLevel
reasons: list[str]
checked_at: AwareDatetime

View File

@ -0,0 +1,27 @@
# generated by datamodel-codegen:
# filename: execution_receipt.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class Channel(StrEnum):
FILE_EXPORT = 'FILE_EXPORT'
TRADING_PLATFORM_API = 'TRADING_PLATFORM_API'
SIMULATION_GATEWAY = 'SIMULATION_GATEWAY'
class ExecutionReceipt(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
receipt_id: constr(min_length=1)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
permit_id: constr(min_length=1)
channel: Channel
idempotency_key: constr(min_length=1)
artifact_ref: constr(min_length=1)
accepted_at: AwareDatetime

View File

@ -0,0 +1,36 @@
# generated by datamodel-codegen:
# filename: router_decision.json
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, constr
class WorkflowId(StrEnum):
day_ahead_situation = 'day-ahead-situation'
day_ahead_bid = 'day-ahead-bid'
adhoc_analysis = 'adhoc-analysis'
class Params(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
class Confidence(StrEnum):
HIGH = 'HIGH'
MEDIUM = 'MEDIUM'
LOW = 'LOW'
class RouterDecision(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
workflow_id: WorkflowId
params: Params
confidence: Confidence

View File

@ -0,0 +1,36 @@
# generated by datamodel-codegen:
# filename: simulation_result.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, conint, constr
class Kind(StrEnum):
REVENUE_SCENARIOS = 'REVENUE_SCENARIOS'
POWER_BALANCE = 'POWER_BALANCE'
class Simulator(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
name: constr(min_length=1)
version: constr(min_length=1)
seed: conint(ge=-9007199254740991, le=9007199254740991)
class SimulationResult(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
kind: Kind
alert: bool
alerts: list[str]
metrics: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')]
scenario_count: conint(ge=0, le=9007199254740991)
simulator: Simulator
simulated_at: AwareDatetime

View File

@ -0,0 +1,19 @@
# generated by datamodel-codegen:
# filename: stale_denial.json
from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
class Reason(RootModel[constr(min_length=1)]):
root: constr(min_length=1)
class StaleDenial(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
reasons: list[Reason] = Field(..., min_length=1)
checked_at: AwareDatetime

View File

@ -0,0 +1,42 @@
# generated by datamodel-codegen:
# filename: validation_result.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class PolicyPack(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
version: constr(min_length=1)
class Severity(StrEnum):
REJECT = 'REJECT'
WARN = 'WARN'
class Violation(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
rule_id: constr(min_length=1)
severity: Severity
message: constr(min_length=1)
class ValidationResult(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
policy_pack: PolicyPack
ok: bool
violations: list[Violation]
rules_evaluated: list[constr(min_length=1)]
checked_at: AwareDatetime