M1: domain schemas, contracts pipeline, ledger and snapshot services

- packages/domain: zod schemas for the 07-scenario object set with a
  schemaRegistry driving schema export, fixture generation, and Python
  module naming; decimal-string/sha256/curve primitives enforce the
  docs/11 §3.3 representation rules at the type layer
- contracts/: 12 JSON Schemas (draft 2020-12), 12 golden fixtures, 3
  invalid fixtures crafted to fail on both sides (float money, bad
  digest, missing concurrency version)
- skills-py: generated pydantic models (committed, never hand-edited),
  regeneration script, mirror pytest using JSON-mode validation —
  dual-side contract tests agree on all 15 fixtures
- packages/services: content-addressed immutable snapshot store
  (memory + fs, rejects floats), LedgerService v1 with optimistic
  concurrency and the P7 constraint cascade (monthly position bounds
  day-ahead bids; no-anchor bids rejected), quality-gate stub
- cascade band shape is an OPEN-QUESTION A5 placeholder (pro-rata
  daily share ±band) — see ledger.ts checkCascade

All green: tsc typecheck, 41 TS tests, 15 Python tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019u5SLNweVio6ozJX7yfxQr

🔮 View transcript: https://logs.lojong.info/s/e8u90k3t33w590r7b5y7yzqh
This commit is contained in:
stewart hu 2026-09-01 21:50:22 -04:00
parent 80835138e9
commit f681e134cc
70 changed files with 6463 additions and 0 deletions

View File

@ -0,0 +1,28 @@
{
"id": "appr-001",
"proposal_digest": "not-a-digest",
"decision": "APPROVE",
"approver": {
"id": "user-trader-01",
"role": "senior-trader"
},
"scope": {
"effect_type": "BID",
"limits": {
"max_energy_mwh": "1500.0"
}
},
"validity": {
"from": "2026-03-14T08:30:00Z",
"to": "2026-03-15T23:59:59Z"
},
"evidence_versions": {
"policy_pack_version": "2026.03",
"ledger_version": 42,
"data_snapshot_refs": [
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
]
},
"comment": "within monthly position band",
"decided_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,110 @@
{
"id": "pos-001",
"timescale": "DAY_AHEAD",
"period": "2026-03-15",
"kind": "AWARD",
"energy_mwh": "1180.0",
"curve": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3"
]
},
"source_ref": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}

View File

@ -0,0 +1,239 @@
{
"id": "prop-001",
"type": "BID",
"timescale": "DAY_AHEAD",
"originator": {
"agent": "trading-agent",
"trigger": "SCHEDULED",
"task_id": "task-001"
},
"payload": {
"kind": "BID",
"market_date": "2026-03-15",
"prices_yuan_per_mwh": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50"
]
},
"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"
]
},
"expected_revenue_yuan": 510600
},
"lineage": {
"tool_calls": [
{
"tool_call_id": "tc-001",
"tool": "bid-optimization-milp",
"version": "1.0.0",
"inputs_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"outputs_ref": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
],
"data_refs": [
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
],
"ledger_version": 42,
"policy_pack_version": "2026.03"
},
"envelope_ref": "env-bid-001",
"digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"status": "DRAFT",
"created_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,28 @@
{
"id": "appr-001",
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"decision": "APPROVE",
"approver": {
"id": "user-trader-01",
"role": "senior-trader"
},
"scope": {
"effect_type": "BID",
"limits": {
"max_energy_mwh": "1500.0"
}
},
"validity": {
"from": "2026-03-14T08:30:00Z",
"to": "2026-03-15T23:59:59Z"
},
"evidence_versions": {
"policy_pack_version": "2026.03",
"ledger_version": 42,
"data_snapshot_refs": [
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
]
},
"comment": "within monthly position band",
"decided_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,23 @@
{
"id": "case-001",
"kind": "DAY_AHEAD_BID",
"objective": "Complete day-ahead bid for 2026-03-15",
"owner": "user-trader-01",
"deadline": "2026-03-14T10:00:00Z",
"status": "AWAITING_APPROVAL",
"refs": {
"evidence": [
"sit-001",
"fc-001"
],
"proposals": [
"prop-001"
],
"approvals": [],
"permits": [],
"scenarios": [],
"outcome": null
},
"opened_at": "2026-03-14T06:00:00Z",
"closed_at": null
}

View File

@ -0,0 +1,30 @@
{
"id": "env-bid-001",
"scope": {
"proposal_type": "BID",
"timescales": [
"DAY_AHEAD"
],
"resource_set": "pool-hubei-01"
},
"bounds": {
"price_deviation_pct": "5.0",
"max_energy_mwh": "1500.0"
},
"validity": {
"from": "2026-03-14T06:00:00Z",
"to": "2026-03-15T23:59:59Z"
},
"approval": {
"level": "L2",
"approved_by": [
"user-ops-lead",
"user-risk-officer"
]
},
"escalation": {
"max_consecutive_deviations": 3,
"deviation_threshold_pct": "10.0"
},
"status": "ACTIVE"
}

View File

@ -0,0 +1,11 @@
{
"event_id": "evt-001",
"event_type": "SituationReportPublished",
"schema_version": "1.0.0",
"occurred_at": "2026-03-14T06:00:00Z",
"causation_id": "task-001",
"correlation_id": "case-001",
"payload": {
"situation_report_id": "sit-001"
}
}

View File

@ -0,0 +1,11 @@
{
"id": "permit-001",
"proposal_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"issued_at": "2026-03-14T08:30:00Z",
"expires_at": "2026-03-15T23:59:59Z",
"effect_limits": {
"max_energy_mwh": "1500.0"
},
"revoked_at": null,
"issuer": "authority-service"
}

View File

@ -0,0 +1,215 @@
{
"id": "flex-001",
"provider": {
"unit_id": "agg-unit-wuhan-01",
"level": "AGGREGATION_UNIT"
},
"market_date": "2026-03-15",
"up_mw": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5",
"8.5"
]
},
"down_mw": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0",
"6.0"
]
},
"valid_until": "2026-03-15T23:59:59Z",
"schema_version": "1.0.0",
"signature": null
}

View File

@ -0,0 +1,320 @@
{
"id": "fc-001",
"kind": "LOAD",
"market_date": "2026-03-15",
"unit": "mw",
"quantiles": {
"p10": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1",
"40.1"
]
},
"p50": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7",
"45.7"
]
},
"p90": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3",
"52.3"
]
}
},
"model": {
"name": "load-forecast",
"version": "1.2.0"
},
"features_snapshot_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"generated_at": "2026-03-14T06:00:00Z"
}

View File

@ -0,0 +1,15 @@
{
"version": 43,
"entries": [
{
"id": "pos-000",
"timescale": "MONTHLY",
"period": "2026-03",
"kind": "CONTRACT",
"energy_mwh": "36000.0",
"curve": null,
"source_ref": "contract-2026-03-001",
"recorded_at": "2026-03-14T06:00:00Z"
}
]
}

View File

@ -0,0 +1,111 @@
{
"id": "pos-001",
"timescale": "DAY_AHEAD",
"period": "2026-03-15",
"kind": "AWARD",
"energy_mwh": "1180.0",
"curve": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3",
"12.3"
]
},
"source_ref": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"expected_version": 42
}

View File

@ -0,0 +1,239 @@
{
"id": "prop-001",
"type": "BID",
"timescale": "DAY_AHEAD",
"originator": {
"agent": "trading-agent",
"trigger": "SCHEDULED",
"task_id": "task-001"
},
"payload": {
"kind": "BID",
"market_date": "2026-03-15",
"prices_yuan_per_mwh": {
"interval_minutes": 15,
"date": "2026-03-15",
"values": [
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50",
"425.50"
]
},
"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"
]
},
"expected_revenue_yuan": "510600.00"
},
"lineage": {
"tool_calls": [
{
"tool_call_id": "tc-001",
"tool": "bid-optimization-milp",
"version": "1.0.0",
"inputs_ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"outputs_ref": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
],
"data_refs": [
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
],
"ledger_version": 42,
"policy_pack_version": "2026.03"
},
"envelope_ref": "env-bid-001",
"digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"status": "DRAFT",
"created_at": "2026-03-14T08:30:00Z"
}

View File

@ -0,0 +1,17 @@
{
"resource_id": "res-storage-01",
"name": "Wuhan industrial park storage #1",
"type": "STORAGE",
"rated_power_mw": "10.0",
"certified_adjustable_mw": "8.0",
"confidence": "0.92",
"reliability_score": "0.88",
"constraints": {
"min_duration_min": 60,
"recovery_rate_mw_per_min": "0.5"
},
"evidence_refs": [
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
],
"updated_at": "2026-03-14T06:00:00Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "sit-001",
"market_date": "2026-03-15",
"risk_level": "LOW",
"findings": [
{
"kind": "TREND",
"summary": "mild temperatures, load near seasonal norm",
"refs": [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
]
}
],
"forecast_refs": [
"fc-001"
],
"generated_at": "2026-03-14T06:00:00Z"
}

View File

@ -0,0 +1,136 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"decision": {
"type": "string",
"enum": [
"APPROVE",
"REJECT"
]
},
"approver": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"role": {
"type": "string",
"minLength": 1
}
},
"required": [
"id",
"role"
],
"additionalProperties": false
},
"scope": {
"type": "object",
"properties": {
"effect_type": {
"type": "string",
"enum": [
"BID",
"INVITATION",
"CONTROL_PLAN",
"DISPATCH_PLAN"
]
},
"limits": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
}
},
"required": [
"effect_type",
"limits"
],
"additionalProperties": false
},
"validity": {
"type": "object",
"properties": {
"from": {
"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))$"
},
"to": {
"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": [
"from",
"to"
],
"additionalProperties": false
},
"evidence_versions": {
"type": "object",
"properties": {
"policy_pack_version": {
"type": "string",
"minLength": 1
},
"ledger_version": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"data_snapshot_refs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
}
},
"required": [
"policy_pack_version",
"ledger_version",
"data_snapshot_refs"
],
"additionalProperties": false
},
"comment": {
"type": "string"
},
"decided_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": [
"id",
"proposal_digest",
"decision",
"approver",
"scope",
"validity",
"evidence_versions",
"decided_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/approval.json",
"title": "Approval"
}

View File

@ -0,0 +1,134 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"kind": {
"type": "string",
"enum": [
"DAY_AHEAD_BID",
"DR_EVENT",
"DEVIATION_RESOLUTION",
"REVIEW",
"ADHOC_ANALYSIS"
]
},
"objective": {
"type": "string",
"minLength": 1
},
"owner": {
"type": "string",
"minLength": 1
},
"deadline": {
"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))$"
},
"status": {
"type": "string",
"enum": [
"OPEN",
"IN_PROGRESS",
"AWAITING_APPROVAL",
"CLOSED_DONE",
"CLOSED_ABORTED"
]
},
"refs": {
"type": "object",
"properties": {
"evidence": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"proposals": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"approvals": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"permits": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"scenarios": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"outcome": {
"anyOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "null"
}
]
}
},
"required": [
"evidence",
"proposals",
"approvals",
"permits",
"scenarios",
"outcome"
],
"additionalProperties": false
},
"opened_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))$"
},
"closed_at": {
"anyOf": [
{
"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))$"
},
{
"type": "null"
}
]
}
},
"required": [
"id",
"kind",
"objective",
"owner",
"deadline",
"status",
"refs",
"opened_at",
"closed_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/decision_case.json",
"title": "DecisionCase"
}

View File

@ -0,0 +1,143 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"scope": {
"type": "object",
"properties": {
"proposal_type": {
"type": "string",
"enum": [
"BID",
"INVITATION",
"CONTROL_PLAN",
"DISPATCH_PLAN"
]
},
"timescales": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"enum": [
"ANNUAL",
"MONTHLY",
"DAY_AHEAD",
"INTRADAY",
"REALTIME"
]
}
},
"resource_set": {
"type": "string",
"minLength": 1
}
},
"required": [
"proposal_type",
"timescales",
"resource_set"
],
"additionalProperties": false
},
"bounds": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
},
"validity": {
"type": "object",
"properties": {
"from": {
"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))$"
},
"to": {
"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": [
"from",
"to"
],
"additionalProperties": false
},
"approval": {
"type": "object",
"properties": {
"level": {
"type": "string",
"enum": [
"L1",
"L2",
"L3"
]
},
"approved_by": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"required": [
"level",
"approved_by"
],
"additionalProperties": false
},
"escalation": {
"type": "object",
"properties": {
"max_consecutive_deviations": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"deviation_threshold_pct": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
},
"required": [
"max_consecutive_deviations",
"deviation_threshold_pct"
],
"additionalProperties": false
},
"status": {
"type": "string",
"enum": [
"ACTIVE",
"SUSPENDED",
"EXPIRED"
]
}
},
"required": [
"id",
"scope",
"bounds",
"validity",
"approval",
"escalation",
"status"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/envelope.json",
"title": "Envelope"
}

View File

@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"event_id": {
"type": "string",
"minLength": 1
},
"event_type": {
"type": "string",
"minLength": 1
},
"schema_version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"occurred_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))$"
},
"causation_id": {
"type": "string",
"minLength": 1
},
"correlation_id": {
"type": "string",
"minLength": 1
},
"payload": {}
},
"required": [
"event_id",
"event_type",
"schema_version",
"occurred_at",
"causation_id",
"correlation_id",
"payload"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/event_envelope.json",
"title": "EventEnvelope"
}

View File

@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"proposal_digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"issued_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))$"
},
"expires_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))$"
},
"effect_limits": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
},
"revoked_at": {
"anyOf": [
{
"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))$"
},
{
"type": "null"
}
]
},
"issuer": {
"type": "string",
"const": "authority-service"
}
},
"required": [
"id",
"proposal_digest",
"issued_at",
"expires_at",
"effect_limits",
"revoked_at",
"issuer"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/execution_permit.json",
"title": "ExecutionPermit"
}

View File

@ -0,0 +1,120 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"provider": {
"type": "object",
"properties": {
"unit_id": {
"type": "string",
"minLength": 1
},
"level": {
"type": "string",
"enum": [
"SITE",
"AGGREGATION_UNIT",
"PROVINCE"
]
}
},
"required": [
"unit_id",
"level"
],
"additionalProperties": false
},
"market_date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"up_mw": {
"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
},
"down_mw": {
"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
},
"valid_until": {
"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))$"
},
"schema_version": {
"type": "string",
"minLength": 1
},
"signature": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"provider",
"market_date",
"up_mw",
"down_mw",
"valid_until",
"schema_version",
"signature"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/flexibility_envelope.json",
"title": "FlexibilityEnvelope"
}

View File

@ -0,0 +1,164 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"kind": {
"type": "string",
"enum": [
"LOAD",
"PV",
"PRICE"
]
},
"market_date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"unit": {
"type": "string",
"enum": [
"mw",
"yuan_per_mwh"
]
},
"quantiles": {
"type": "object",
"properties": {
"p10": {
"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
},
"p50": {
"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
},
"p90": {
"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
}
},
"required": [
"p10",
"p50",
"p90"
],
"additionalProperties": false
},
"model": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"minLength": 1
}
},
"required": [
"name",
"version"
],
"additionalProperties": false
},
"features_snapshot_ref": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"generated_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": [
"id",
"kind",
"market_date",
"unit",
"quantiles",
"model",
"features_snapshot_ref",
"generated_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/forecast_bundle.json",
"title": "ForecastBundle"
}

View File

@ -0,0 +1,112 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"version": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"timescale": {
"type": "string",
"enum": [
"ANNUAL",
"MONTHLY",
"DAY_AHEAD",
"INTRADAY",
"REALTIME"
]
},
"period": {
"type": "string",
"pattern": "^\\d{4}(-\\d{2}(-\\d{2})?)?$"
},
"kind": {
"type": "string",
"enum": [
"CONTRACT",
"BID_SUBMITTED",
"AWARD",
"EXTERNAL_COMMITMENT"
]
},
"energy_mwh": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
"curve": {
"anyOf": [
{
"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
},
{
"type": "null"
}
]
},
"source_ref": {
"type": "string",
"minLength": 1
},
"recorded_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": [
"id",
"timescale",
"period",
"kind",
"energy_mwh",
"curve",
"source_ref",
"recorded_at"
],
"additionalProperties": false
}
}
},
"required": [
"version",
"entries"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/ledger_view.json",
"title": "LedgerView"
}

View File

@ -0,0 +1,94 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"timescale": {
"type": "string",
"enum": [
"ANNUAL",
"MONTHLY",
"DAY_AHEAD",
"INTRADAY",
"REALTIME"
]
},
"period": {
"type": "string",
"pattern": "^\\d{4}(-\\d{2}(-\\d{2})?)?$"
},
"kind": {
"type": "string",
"enum": [
"CONTRACT",
"BID_SUBMITTED",
"AWARD",
"EXTERNAL_COMMITMENT"
]
},
"energy_mwh": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
"curve": {
"anyOf": [
{
"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
},
{
"type": "null"
}
]
},
"source_ref": {
"type": "string",
"minLength": 1
},
"expected_version": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"id",
"timescale",
"period",
"kind",
"energy_mwh",
"curve",
"source_ref",
"expected_version"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/position_update.json",
"title": "PositionUpdate"
}

View File

@ -0,0 +1,255 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"oneOf": [
{
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"timescale": {
"type": "string",
"enum": [
"ANNUAL",
"MONTHLY",
"DAY_AHEAD",
"INTRADAY",
"REALTIME"
]
},
"originator": {
"type": "object",
"properties": {
"agent": {
"type": "string",
"minLength": 1
},
"trigger": {
"type": "string",
"enum": [
"MANUAL",
"EVENT",
"SCHEDULED"
]
},
"task_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"agent",
"trigger",
"task_id"
],
"additionalProperties": false
},
"lineage": {
"type": "object",
"properties": {
"tool_calls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tool_call_id": {
"type": "string",
"minLength": 1
},
"tool": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"minLength": 1
},
"inputs_ref": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"outputs_ref": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
},
"required": [
"tool_call_id",
"tool",
"version",
"inputs_ref",
"outputs_ref"
],
"additionalProperties": false
}
},
"data_refs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
},
"ledger_version": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"policy_pack_version": {
"type": "string",
"minLength": 1
}
},
"required": [
"tool_calls",
"data_refs",
"ledger_version",
"policy_pack_version"
],
"additionalProperties": false
},
"envelope_ref": {
"anyOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "null"
}
]
},
"digest": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"status": {
"type": "string",
"enum": [
"DRAFT",
"RULE_CHECK",
"SIMULATION",
"ENVELOPE_CHECK",
"PENDING_HUMAN",
"AUTO_APPROVED",
"APPROVED",
"FRESH_CHECK",
"STALE",
"AUTHORIZED",
"RELEASED",
"EXECUTING",
"COMPLETED",
"ROLLED_BACK",
"REJECTED"
]
},
"created_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))$"
},
"type": {
"type": "string",
"const": "BID"
},
"payload": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "BID"
},
"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
},
"expected_revenue_yuan": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
}
},
"required": [
"kind",
"market_date",
"prices_yuan_per_mwh",
"quantities_mwh",
"expected_revenue_yuan"
],
"additionalProperties": false
}
},
"required": [
"id",
"timescale",
"originator",
"lineage",
"envelope_ref",
"digest",
"status",
"created_at",
"type",
"payload"
],
"additionalProperties": false
}
],
"$id": "https://vpp-ai-platform/contracts/proposal.json",
"title": "Proposal"
}

View File

@ -0,0 +1,102 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"resource_id": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": [
"STORAGE",
"PV",
"EV_CHARGER",
"HVAC",
"INDUSTRIAL_LOAD",
"MICROGRID",
"BASE_STATION"
]
},
"rated_power_mw": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
"certified_adjustable_mw": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
"confidence": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
"reliability_score": {
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
"constraints": {
"type": "object",
"properties": {
"min_duration_min": {
"anyOf": [
{
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
{
"type": "null"
}
]
},
"recovery_rate_mw_per_min": {
"anyOf": [
{
"type": "string",
"pattern": "^-?\\d+(\\.\\d+)?$"
},
{
"type": "null"
}
]
}
},
"required": [
"min_duration_min",
"recovery_rate_mw_per_min"
],
"additionalProperties": false
},
"evidence_refs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
},
"updated_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": [
"resource_id",
"name",
"type",
"rated_power_mw",
"certified_adjustable_mw",
"confidence",
"reliability_score",
"constraints",
"evidence_refs",
"updated_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/resource_profile.json",
"title": "ResourceProfile"
}

View File

@ -0,0 +1,80 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"market_date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
},
"risk_level": {
"type": "string",
"enum": [
"LOW",
"MEDIUM",
"HIGH",
"EXTREME"
]
},
"findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": [
"TREND",
"ANOMALY",
"RISK",
"ATTRIBUTION"
]
},
"summary": {
"type": "string",
"minLength": 1
},
"refs": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
}
}
},
"required": [
"kind",
"summary",
"refs"
],
"additionalProperties": false
}
},
"forecast_refs": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"generated_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": [
"id",
"market_date",
"risk_level",
"findings",
"forecast_refs",
"generated_at"
],
"additionalProperties": false,
"$id": "https://vpp-ai-platform/contracts/situation_report.json",
"title": "SituationReport"
}

1700
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "vpp-ai-platform",
"private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"test": "npm run test --workspaces --if-present",
"export:schemas": "npm run export:schemas -w @vpp/domain",
"make:fixtures": "npm run make:fixtures -w @vpp/domain",
"check": "npm run export:schemas && npm test"
}
}

View File

@ -0,0 +1,23 @@
{
"name": "@vpp/domain",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"export:schemas": "tsx scripts/export-schemas.ts",
"make:fixtures": "tsx scripts/make-fixtures.ts"
},
"dependencies": {
"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,22 @@
/**
* Exports every registry schema to contracts/schema/<name>.json (JSON Schema,
* draft 2020-12). These committed files are the neutral cross-language contract
* (ADR-0002); the Python side generates pydantic models from them.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { z } from 'zod'
import { schemaRegistry } from '../src/index.js'
const outDir = fileURLToPath(new URL('../../../contracts/schema/', import.meta.url))
mkdirSync(outDir, { recursive: true })
const pascal = (s: string) => s.split('_').map((w) => w[0]!.toUpperCase() + w.slice(1)).join('')
for (const [name, schema] of Object.entries(schemaRegistry)) {
const json = z.toJSONSchema(schema) as Record<string, unknown>
json['$id'] = `https://vpp-ai-platform/contracts/${name}.json`
json['title'] = pascal(name) // becomes the pydantic class name
writeFileSync(`${outDir}${name}.json`, JSON.stringify(json, null, 2) + '\n')
console.log(`exported ${name}.json`)
}

View File

@ -0,0 +1,244 @@
/**
* Generates golden fixtures into contracts/fixtures/<name>/*.json and invalid
* fixtures into contracts/fixtures-invalid/<name>/*.json. Run once and commit;
* fixtures are reviewed via diff. Invalid cases are crafted to fail on BOTH the
* zod and pydantic sides (dual-side contract test, docs/11 §3.2).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { schemaRegistry } from '../src/index.js'
const root = fileURLToPath(new URL('../../../contracts/', import.meta.url))
const REF_A = 'a'.repeat(64)
const REF_B = 'b'.repeat(64)
const REF_C = 'c'.repeat(64)
const DATE = '2026-03-15'
const T0 = '2026-03-14T06:00:00Z'
const T1 = '2026-03-14T08:30:00Z'
const T2 = '2026-03-15T23:59:59Z'
const curve = (v: string, date = DATE) => ({
interval_minutes: 15,
date,
values: Array.from({ length: 96 }, () => v),
})
const valid: Record<string, Record<string, unknown>> = {
proposal: {
'bid-basic': {
id: 'prop-001',
type: 'BID',
timescale: 'DAY_AHEAD',
originator: { agent: 'trading-agent', trigger: 'SCHEDULED', task_id: 'task-001' },
payload: {
kind: 'BID',
market_date: DATE,
prices_yuan_per_mwh: curve('425.50'),
quantities_mwh: curve('12.5'),
expected_revenue_yuan: '510600.00',
},
lineage: {
tool_calls: [
{
tool_call_id: 'tc-001',
tool: 'bid-optimization-milp',
version: '1.0.0',
inputs_ref: REF_A,
outputs_ref: REF_B,
},
],
data_refs: [REF_C],
ledger_version: 42,
policy_pack_version: '2026.03',
},
envelope_ref: 'env-bid-001',
digest: REF_B,
status: 'DRAFT',
created_at: T1,
},
},
approval: {
approve: {
id: 'appr-001',
proposal_digest: REF_B,
decision: 'APPROVE',
approver: { id: 'user-trader-01', role: 'senior-trader' },
scope: { effect_type: 'BID', limits: { max_energy_mwh: '1500.0' } },
validity: { from: T1, to: T2 },
evidence_versions: {
policy_pack_version: '2026.03',
ledger_version: 42,
data_snapshot_refs: [REF_C],
},
comment: 'within monthly position band',
decided_at: T1,
},
},
execution_permit: {
active: {
id: 'permit-001',
proposal_digest: REF_B,
issued_at: T1,
expires_at: T2,
effect_limits: { max_energy_mwh: '1500.0' },
revoked_at: null,
issuer: 'authority-service',
},
},
envelope: {
'bid-envelope': {
id: 'env-bid-001',
scope: { proposal_type: 'BID', timescales: ['DAY_AHEAD'], resource_set: 'pool-hubei-01' },
bounds: { price_deviation_pct: '5.0', max_energy_mwh: '1500.0' },
validity: { from: T0, to: T2 },
approval: { level: 'L2', approved_by: ['user-ops-lead', 'user-risk-officer'] },
escalation: { max_consecutive_deviations: 3, deviation_threshold_pct: '10.0' },
status: 'ACTIVE',
},
},
flexibility_envelope: {
'da-aggregation-unit': {
id: 'flex-001',
provider: { unit_id: 'agg-unit-wuhan-01', level: 'AGGREGATION_UNIT' },
market_date: DATE,
up_mw: curve('8.5'),
down_mw: curve('6.0'),
valid_until: T2,
schema_version: '1.0.0',
signature: null,
},
},
position_update: {
'award-da': {
id: 'pos-001',
timescale: 'DAY_AHEAD',
period: DATE,
kind: 'AWARD',
energy_mwh: '1180.0',
curve: curve('12.3'),
source_ref: REF_B,
expected_version: 42,
},
},
ledger_view: {
basic: {
version: 43,
entries: [
{
id: 'pos-000',
timescale: 'MONTHLY',
period: '2026-03',
kind: 'CONTRACT',
energy_mwh: '36000.0',
curve: null,
source_ref: 'contract-2026-03-001',
recorded_at: T0,
},
],
},
},
forecast_bundle: {
'load-da': {
id: 'fc-001',
kind: 'LOAD',
market_date: DATE,
unit: 'mw',
quantiles: { p10: curve('40.1'), p50: curve('45.7'), p90: curve('52.3') },
model: { name: 'load-forecast', version: '1.2.0' },
features_snapshot_ref: REF_A,
generated_at: T0,
},
},
situation_report: {
'normal-day': {
id: 'sit-001',
market_date: DATE,
risk_level: 'LOW',
findings: [
{ kind: 'TREND', summary: 'mild temperatures, load near seasonal norm', refs: [REF_A] },
],
forecast_refs: ['fc-001'],
generated_at: T0,
},
},
resource_profile: {
storage: {
resource_id: 'res-storage-01',
name: 'Wuhan industrial park storage #1',
type: 'STORAGE',
rated_power_mw: '10.0',
certified_adjustable_mw: '8.0',
confidence: '0.92',
reliability_score: '0.88',
constraints: { min_duration_min: 60, recovery_rate_mw_per_min: '0.5' },
evidence_refs: [REF_C],
updated_at: T0,
},
},
decision_case: {
'da-bid': {
id: 'case-001',
kind: 'DAY_AHEAD_BID',
objective: 'Complete day-ahead bid for 2026-03-15',
owner: 'user-trader-01',
deadline: '2026-03-14T10:00:00Z',
status: 'AWAITING_APPROVAL',
refs: {
evidence: ['sit-001', 'fc-001'],
proposals: ['prop-001'],
approvals: [],
permits: [],
scenarios: [],
outcome: null,
},
opened_at: T0,
closed_at: null,
},
},
event_envelope: {
'situation-published': {
event_id: 'evt-001',
event_type: 'SituationReportPublished',
schema_version: '1.0.0',
occurred_at: T0,
causation_id: 'task-001',
correlation_id: 'case-001',
payload: { situation_report_id: 'sit-001' },
},
},
}
// Each invalid case breaks exactly one rule and must fail on both sides.
const invalid: Record<string, Record<string, unknown>> = {
proposal: {
// money as JSON float — the forbidden representation (docs/11 §3.3)
'bid-float-money': structuredClone(valid['proposal']!['bid-basic']!),
},
approval: {
// digest not sha256 hex
'bad-digest': { ...(valid['approval']!['approve'] as object), proposal_digest: 'not-a-digest' },
},
position_update: {
// missing optimistic-concurrency field
'missing-version': (() => {
const o = structuredClone(valid['position_update']!['award-da']) as Record<string, unknown>
delete o['expected_version']
return o
})(),
},
}
;(invalid['proposal']!['bid-float-money'] as any).payload.expected_revenue_yuan = 510600.0
const write = (dir: string, sets: Record<string, Record<string, unknown>>) => {
for (const [name, cases] of Object.entries(sets)) {
mkdirSync(`${root}${dir}/${name}`, { recursive: true })
for (const [caseName, obj] of Object.entries(cases)) {
writeFileSync(`${root}${dir}/${name}/${caseName}.json`, JSON.stringify(obj, null, 2) + '\n')
}
}
}
write('fixtures', valid)
write('fixtures-invalid', invalid)
console.log('fixtures written')

View File

@ -0,0 +1,38 @@
import { z } from 'zod'
import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js'
import { ProposalType } from './proposal.js'
export const ApprovalLevel = z.enum(['L1', 'L2', 'L3'])
/** Binds to an exact proposal digest and validity window; stale when evidence changes (docs/03 §2.1). */
export const Approval = z.object({
id: Id,
proposal_digest: SnapshotRef,
decision: z.enum(['APPROVE', 'REJECT']),
approver: z.object({ id: Id, role: z.string().min(1) }),
scope: z.object({
effect_type: ProposalType,
limits: z.record(z.string(), DecimalString),
}),
validity: z.object({ from: IsoUtc, to: IsoUtc }),
evidence_versions: z.object({
policy_pack_version: z.string().min(1),
ledger_version: z.int().nonnegative(),
data_snapshot_refs: z.array(SnapshotRef),
}),
comment: z.string().optional(),
decided_at: IsoUtc,
})
export type Approval = z.infer<typeof Approval>
/** Short-lived, revocable. Gateways accept only (Proposal, Permit) pairs (I4, docs/03 §2.2). */
export const ExecutionPermit = z.object({
id: Id,
proposal_digest: SnapshotRef,
issued_at: IsoUtc,
expires_at: IsoUtc,
effect_limits: z.record(z.string(), DecimalString),
revoked_at: IsoUtc.nullable(),
issuer: z.literal('authority-service'),
})
export type ExecutionPermit = z.infer<typeof ExecutionPermit>

View File

@ -0,0 +1,28 @@
import { z } from 'zod'
import { Id, IsoUtc } from './common.js'
export const CaseKind = z.enum(['DAY_AHEAD_BID', 'DR_EVENT', 'DEVIATION_RESOLUTION', 'REVIEW', 'ADHOC_ANALYSIS'])
/**
* Operator's unit of accountability (docs/02 §5, ADR-0008): one objective,
* one owner, one deadline, a completion contract. References, never copies.
*/
export const DecisionCase = z.object({
id: Id,
kind: CaseKind,
objective: z.string().min(1),
owner: Id,
deadline: IsoUtc,
status: z.enum(['OPEN', 'IN_PROGRESS', 'AWAITING_APPROVAL', 'CLOSED_DONE', 'CLOSED_ABORTED']),
refs: z.object({
evidence: z.array(Id),
proposals: z.array(Id),
approvals: z.array(Id),
permits: z.array(Id),
scenarios: z.array(Id),
outcome: Id.nullable(),
}),
opened_at: IsoUtc,
closed_at: IsoUtc.nullable(),
})
export type DecisionCase = z.infer<typeof DecisionCase>

View File

@ -0,0 +1,35 @@
import { z } from 'zod'
/**
* Fixed-point decimal encoded as a string. Money/energy/prices must never be
* JS numbers or JSON floats (docs/11 §3.3) — TS `number` and Python `float`
* disagree exactly where settlement reconciliation cares.
*/
export const DecimalString = z
.string()
.regex(/^-?\d+(\.\d+)?$/, 'fixed-point decimal encoded as string')
export type DecimalString = z.infer<typeof DecimalString>
/** ISO8601 UTC timestamp. Market-interval semantics use {date, interval} instead. */
export const IsoUtc = z.iso.datetime()
/** Market date in Asia/Shanghai semantics (docs/11 §3.3). */
export const MarketDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
export const MarketMonth = z.string().regex(/^\d{4}-\d{2}$/)
export const Id = z.string().min(1)
/** sha256 hex reference to an immutable content-addressed snapshot. */
export const SnapshotRef = z.string().regex(/^[0-9a-f]{64}$/)
export type SnapshotRef = z.infer<typeof SnapshotRef>
export const Timescale = z.enum(['ANNUAL', 'MONTHLY', 'DAY_AHEAD', 'INTRADAY', 'REALTIME'])
export type Timescale = z.infer<typeof Timescale>
/** 96 × 15-min values for one market date; index 0 = 00:00–00:15 Asia/Shanghai. */
export const Curve96 = z.object({
interval_minutes: z.literal(15),
date: MarketDate,
values: z.array(DecimalString).length(96),
})
export type Curve96 = z.infer<typeof Curve96>

View File

@ -0,0 +1,48 @@
import { z } from 'zod'
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, Timescale } from './common.js'
import { ApprovalLevel } from './approval.js'
import { ProposalType } from './proposal.js'
/**
* Authority envelope (授权包络, downward): human-pre-approved autonomy bounds.
* Bound keys are type-specific and documented in docs/03 §3; initial values are
* OPEN-QUESTION B1/B2 — never hardcode them.
*/
export const Envelope = z.object({
id: Id,
scope: z.object({
proposal_type: ProposalType,
timescales: z.array(Timescale).min(1),
resource_set: Id,
}),
bounds: z.record(z.string(), DecimalString),
validity: z.object({ from: IsoUtc, to: IsoUtc }),
approval: z.object({ level: ApprovalLevel, approved_by: z.array(Id).min(1) }),
escalation: z.object({
max_consecutive_deviations: z.int().positive(),
deviation_threshold_pct: DecimalString,
}),
status: z.enum(['ACTIVE', 'SUSPENDED', 'EXPIRED']),
})
export type Envelope = z.infer<typeof Envelope>
/**
* Capability envelope (灵活性包络, upward): adjustable capacity report from an
* aggregation unit — the same schema crosses the federation boundary in
* phase 2 (docs/10 §5, no-rework reservation #1).
*/
export const FlexibilityEnvelope = z.object({
id: Id,
provider: z.object({
unit_id: Id,
level: z.enum(['SITE', 'AGGREGATION_UNIT', 'PROVINCE']),
}),
market_date: MarketDate,
up_mw: Curve96,
down_mw: Curve96,
valid_until: IsoUtc,
schema_version: z.string().min(1),
/** Signing arrives with federation (phase 2); null within-province. */
signature: z.string().nullable(),
})
export type FlexibilityEnvelope = z.infer<typeof FlexibilityEnvelope>

View File

@ -0,0 +1,17 @@
import { z } from 'zod'
import { Id, IsoUtc } from './common.js'
/**
* Typed event wrapper for the outbox/bus (docs/11 §1.3). causation/correlation
* ids let audit and review walk causal chains through the event log.
*/
export const EventEnvelope = z.object({
event_id: Id,
event_type: z.string().min(1),
schema_version: z.string().regex(/^\d+\.\d+\.\d+$/),
occurred_at: IsoUtc,
causation_id: Id,
correlation_id: Id,
payload: z.unknown(),
})
export type EventEnvelope = z.infer<typeof EventEnvelope>

View File

@ -0,0 +1,22 @@
import { z } from 'zod'
import { Curve96, Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
/**
* Forecast output contract (docs/05 §2.1): quantile intervals are mandatory —
* bid risk assessment depends on calibrated bands, not point values.
*/
export const ForecastBundle = z.object({
id: Id,
kind: z.enum(['LOAD', 'PV', 'PRICE']),
market_date: MarketDate,
unit: z.enum(['mw', 'yuan_per_mwh']),
quantiles: z.object({
p10: Curve96,
p50: Curve96,
p90: Curve96,
}),
model: z.object({ name: z.string().min(1), version: z.string().min(1) }),
features_snapshot_ref: SnapshotRef,
generated_at: IsoUtc,
})
export type ForecastBundle = z.infer<typeof ForecastBundle>

View File

@ -0,0 +1,40 @@
import { z } from 'zod'
import { Approval, ExecutionPermit } from './approval.js'
import { DecisionCase } from './case.js'
import { Envelope, FlexibilityEnvelope } from './envelope.js'
import { EventEnvelope } from './event.js'
import { ForecastBundle } from './forecast.js'
import { LedgerView, PositionUpdate } from './ledger.js'
import { Proposal } from './proposal.js'
import { ResourceProfile } from './resource.js'
import { SituationReport } from './situation.js'
export * from './common.js'
export * from './proposal.js'
export * from './approval.js'
export * from './envelope.js'
export * from './ledger.js'
export * from './forecast.js'
export * from './situation.js'
export * from './resource.js'
export * from './case.js'
export * from './event.js'
/**
* Registry driving the contracts pipeline: keys become schema/fixture/module
* names on both the TS and Python sides (underscores — Python module names).
*/
export const schemaRegistry: Record<string, z.ZodType> = {
proposal: Proposal,
approval: Approval,
execution_permit: ExecutionPermit,
envelope: Envelope,
flexibility_envelope: FlexibilityEnvelope,
position_update: PositionUpdate,
ledger_view: LedgerView,
forecast_bundle: ForecastBundle,
situation_report: SituationReport,
resource_profile: ResourceProfile,
decision_case: DecisionCase,
event_envelope: EventEnvelope,
}

View File

@ -0,0 +1,32 @@
import { z } from 'zod'
import { Curve96, DecimalString, Id, IsoUtc, Timescale } from './common.js'
export const PositionKind = z.enum(['CONTRACT', 'BID_SUBMITTED', 'AWARD', 'EXTERNAL_COMMITMENT'])
/** Period granularity follows timescale: YYYY | YYYY-MM | YYYY-MM-DD. */
const Period = z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/)
export const PositionUpdate = z.object({
id: Id,
timescale: Timescale,
period: Period,
kind: PositionKind,
energy_mwh: DecimalString,
curve: Curve96.nullable(),
/** Proposal digest, settlement id, or contract id that caused this entry. */
source_ref: z.string().min(1),
/** Optimistic concurrency: append fails unless this matches current version. */
expected_version: z.int().nonnegative(),
})
export type PositionUpdate = z.infer<typeof PositionUpdate>
export const PositionEntry = PositionUpdate.omit({ expected_version: true }).extend({
recorded_at: IsoUtc,
})
export type PositionEntry = z.infer<typeof PositionEntry>
export const LedgerView = z.object({
version: z.int().nonnegative(),
entries: z.array(PositionEntry),
})
export type LedgerView = z.infer<typeof LedgerView>

View File

@ -0,0 +1,75 @@
import { z } from 'zod'
import { Curve96, DecimalString, Id, IsoUtc, MarketDate, SnapshotRef, Timescale } from './common.js'
export const ProposalType = z.enum(['BID', 'INVITATION', 'CONTROL_PLAN', 'DISPATCH_PLAN'])
export type ProposalType = z.infer<typeof ProposalType>
export const TriggerKind = z.enum(['MANUAL', 'EVENT', 'SCHEDULED'])
/** Full lifecycle per docs/03 §2 (incl. FRESH_CHECK/STALE/AUTHORIZED from the staleness model). */
export const ProposalStatus = z.enum([
'DRAFT',
'RULE_CHECK',
'SIMULATION',
'ENVELOPE_CHECK',
'PENDING_HUMAN',
'AUTO_APPROVED',
'APPROVED',
'FRESH_CHECK',
'STALE',
'AUTHORIZED',
'RELEASED',
'EXECUTING',
'COMPLETED',
'ROLLED_BACK',
'REJECTED',
])
export const ToolCallRef = z.object({
tool_call_id: Id,
tool: Id,
version: z.string().min(1),
inputs_ref: SnapshotRef,
outputs_ref: SnapshotRef,
})
/**
* Provenance attached to every proposal (docs/03 §1). Rule check enforces that
* every numeric payload field traces to one of these tool calls (invariant/P2).
*/
export const Lineage = z.object({
tool_calls: z.array(ToolCallRef),
data_refs: z.array(SnapshotRef),
ledger_version: z.int().nonnegative(),
policy_pack_version: z.string().min(1),
})
/** Day-ahead bid: per-interval price/quantity pairs. */
export const BidPayload = z.object({
kind: z.literal('BID'),
market_date: MarketDate,
prices_yuan_per_mwh: Curve96,
quantities_mwh: Curve96,
expected_revenue_yuan: DecimalString,
})
const ProposalBase = z.object({
id: Id,
timescale: Timescale,
originator: z.object({ agent: Id, trigger: TriggerKind, task_id: Id }),
lineage: Lineage,
envelope_ref: Id.nullable(),
/** sha256 over canonical payload + lineage. Approvals and permits bind to this (I3/I4). */
digest: SnapshotRef,
status: ProposalStatus,
created_at: IsoUtc,
})
/**
* M1 carries the BID variant only; INVITATION / CONTROL_PLAN / DISPATCH_PLAN
* payloads arrive with their milestones (ROADMAP M4 / phase 2).
*/
export const Proposal = z.discriminatedUnion('type', [
ProposalBase.extend({ type: z.literal('BID'), payload: BidPayload }),
])
export type Proposal = z.infer<typeof Proposal>

View File

@ -0,0 +1,36 @@
import { z } from 'zod'
import { DecimalString, Id, IsoUtc, SnapshotRef } from './common.js'
export const ResourceType = z.enum([
'STORAGE',
'PV',
'EV_CHARGER',
'HVAC',
'INDUSTRIAL_LOAD',
'MICROGRID',
'BASE_STATION',
])
/**
* Resource profile (docs/02 §2.2). reliability_score is written back by the
* review loop; certified capacity must reference recent execution evidence
* (profile staleness is top-3 risk, docs/13 §0).
*/
export const ResourceProfile = z.object({
resource_id: Id,
name: z.string().min(1),
type: ResourceType,
rated_power_mw: DecimalString,
certified_adjustable_mw: DecimalString,
/** 0–1 confidence in the certified figure. */
confidence: DecimalString,
/** 0–1, updated by ReviewFinding writebacks. */
reliability_score: DecimalString,
constraints: z.object({
min_duration_min: z.int().positive().nullable(),
recovery_rate_mw_per_min: DecimalString.nullable(),
}),
evidence_refs: z.array(SnapshotRef),
updated_at: IsoUtc,
})
export type ResourceProfile = z.infer<typeof ResourceProfile>

View File

@ -0,0 +1,21 @@
import { z } from 'zod'
import { Id, IsoUtc, MarketDate, SnapshotRef } from './common.js'
export const RiskLevel = z.enum(['LOW', 'MEDIUM', 'HIGH', 'EXTREME'])
/** Analysis agent output (docs/02 §2.1). EXTREME triggers the abnormal-day protocol (docs/13 §1). */
export const SituationReport = z.object({
id: Id,
market_date: MarketDate,
risk_level: RiskLevel,
findings: z.array(
z.object({
kind: z.enum(['TREND', 'ANOMALY', 'RISK', 'ATTRIBUTION']),
summary: z.string().min(1),
refs: z.array(SnapshotRef),
}),
),
forecast_refs: z.array(Id),
generated_at: IsoUtc,
})
export type SituationReport = z.infer<typeof SituationReport>

View File

@ -0,0 +1,41 @@
import { readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { schemaRegistry } from '../src/index.js'
const root = fileURLToPath(new URL('../../../contracts/', import.meta.url))
const casesIn = (dir: string): Array<[string, string, string]> => {
const out: Array<[string, string, string]> = []
for (const name of readdirSync(`${root}${dir}`)) {
for (const file of readdirSync(`${root}${dir}/${name}`)) {
out.push([name, file, `${root}${dir}/${name}/${file}`])
}
}
return out
}
describe('golden fixtures validate against domain schemas', () => {
it.each(casesIn('fixtures'))('%s / %s', (name, _file, path) => {
const schema = schemaRegistry[name]
expect(schema, `no schema registered for fixture dir '${name}'`).toBeDefined()
const data = JSON.parse(readFileSync(path, 'utf8'))
const result = schema!.safeParse(data)
expect(result.success, JSON.stringify(result.error?.issues, null, 2)).toBe(true)
})
})
describe('invalid fixtures are rejected', () => {
it.each(casesIn('fixtures-invalid'))('%s / %s', (name, _file, path) => {
const schema = schemaRegistry[name]
expect(schema).toBeDefined()
const data = JSON.parse(readFileSync(path, 'utf8'))
expect(schema!.safeParse(data).success).toBe(false)
})
})
describe('every registered schema has at least one golden fixture', () => {
it.each(Object.keys(schemaRegistry))('%s', (name) => {
expect(readdirSync(`${root}fixtures/${name}`).length).toBeGreaterThan(0)
})
})

View File

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

View File

@ -0,0 +1,21 @@
{
"name": "@vpp/services",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@vpp/domain": "*",
"decimal.js-light": "^2.5.1"
},
"devDependencies": {
"@types/node": "^26.4.1",
"typescript": "^5.6.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,3 @@
export * from './snapshot.js'
export * from './ledger.js'
export * from './quality.js'

View File

@ -0,0 +1,102 @@
import { Decimal } from 'decimal.js-light'
import type { LedgerView, PositionEntry, PositionUpdate, Timescale } from '@vpp/domain'
import { PositionUpdate as PositionUpdateSchema } from '@vpp/domain'
export class LedgerConcurrencyError extends Error {
constructor(expected: number, actual: number) {
super(`ledger version mismatch: update expected ${expected}, current is ${actual}`)
this.name = 'LedgerConcurrencyError'
}
}
export class CascadeViolation extends Error {
constructor(message: string) {
super(message)
this.name = 'CascadeViolation'
}
}
export interface LedgerConfig {
/**
* Allowed relative deviation of a day-ahead bid's daily energy from the
* pro-rata daily share of the monthly contracted position.
* OPEN-QUESTION A5: the real Hubei decomposition-band rule may differ in
* shape (cumulative month-to-date band? asymmetric?) — see docs/open-questions.md.
*/
daMonthlyDeviationBand: string // e.g. "0.05" = ±5%
clock?: () => string
}
/**
* Position ledger v1 (docs/00 §4, P7): in-memory, optimistic concurrency,
* per-timescale views, and the constraint cascade — upper-timescale positions
* bound lower-timescale writes. Storage adapter (Postgres) arrives with M3.
*/
export class LedgerService {
private version = 0
private entries: PositionEntry[] = []
constructor(private readonly cfg: LedgerConfig) {}
read(): LedgerView {
return { version: this.version, entries: [...this.entries] }
}
viewByTimescale(timescale: Timescale): PositionEntry[] {
return this.entries.filter((e) => e.timescale === timescale)
}
append(update: PositionUpdate): LedgerView {
PositionUpdateSchema.parse(update)
if (update.expected_version !== this.version) {
throw new LedgerConcurrencyError(update.expected_version, this.version)
}
this.checkCascade(update)
const { expected_version: _ignored, ...rest } = update
const entry: PositionEntry = {
...rest,
recorded_at: this.cfg.clock?.() ?? new Date().toISOString(),
}
this.entries.push(entry)
this.version += 1
return this.read()
}
/**
* Constraint cascade (P7): a DAY_AHEAD bid submission must stay within the
* band around the pro-rata daily share of the MONTHLY contracted position.
* No monthly position for the month → nothing to cascade from → reject
* (bidding without a position of record is a lineage failure, not a default-allow).
*/
private checkCascade(update: PositionUpdate): void {
if (update.kind !== 'BID_SUBMITTED' || update.timescale !== 'DAY_AHEAD') return
const month = update.period.slice(0, 7)
const monthly = this.entries.filter(
(e) => e.timescale === 'MONTHLY' && e.kind === 'CONTRACT' && e.period === month,
)
if (monthly.length === 0) {
throw new CascadeViolation(
`no MONTHLY CONTRACT position recorded for ${month}; day-ahead bid has no cascade anchor`,
)
}
const contracted = monthly.reduce((sum, e) => sum.add(new Decimal(e.energy_mwh)), new Decimal(0))
const daysInMonth = new Date(
Date.UTC(Number(month.slice(0, 4)), Number(month.slice(5, 7)), 0),
).getUTCDate()
const dailyShare = contracted.div(daysInMonth)
const band = new Decimal(this.cfg.daMonthlyDeviationBand)
const min = dailyShare.mul(new Decimal(1).sub(band))
const max = dailyShare.mul(new Decimal(1).add(band))
const bid = new Decimal(update.energy_mwh)
if (bid.lt(min) || bid.gt(max)) {
throw new CascadeViolation(
`day-ahead bid ${bid.toString()} MWh outside monthly cascade band ` +
`[${min.toString()}, ${max.toString()}] (contracted ${contracted.toString()} MWh / ${daysInMonth} days ± ${band.mul(100).toString()}%)`,
)
}
}
}

View File

@ -0,0 +1,20 @@
import type { Curve96 } from '@vpp/domain'
export interface QualityVerdict {
ok: boolean
issues: string[]
}
/**
* Data-quality gate stub (docs/05 §3.2). Full checks (missing-rate thresholds,
* jump detection, timestamp alignment, drift) arrive with the ingestion
* pipeline in M1 follow-up work; the interface is fixed now so callers can
* depend on it. Quality flags propagate through lineage (docs/13 §5).
*/
export function qualityGate(curve: Curve96): QualityVerdict {
const issues: string[] = []
if (new Set(curve.values).size === 1 && curve.values[0] === '0') {
issues.push('flat-zero curve: likely telemetry outage, not real data')
}
return { ok: issues.length === 0, issues }
}

View File

@ -0,0 +1,85 @@
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
/**
* Content-addressed immutable snapshot store (invariant I7: decisions must be
* replayable from evidence). The ref IS the sha256 of canonical content, so
* a stored snapshot can never change under its ref by construction.
*/
export interface SnapshotStore {
put(value: unknown): string
get(ref: string): unknown | undefined
has(ref: string): boolean
}
/** Deterministic JSON: recursively sorted object keys, no whitespace variance. */
export const canonicalJson = (value: unknown): string =>
JSON.stringify(sortKeys(value))
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys)
if (v !== null && typeof v === 'object') {
return Object.fromEntries(
Object.entries(v as Record<string, unknown>)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([k, val]) => [k, sortKeys(val)]),
)
}
if (typeof v === 'number' && !Number.isInteger(v)) {
// Floats in snapshots would make canonicalization platform-dependent and
// violate the decimal-string rule upstream (docs/11 §3.3).
throw new Error('non-integer number in snapshot content — use decimal strings')
}
return v
}
export const contentRef = (canonical: string): string =>
createHash('sha256').update(canonical, 'utf8').digest('hex')
export class MemorySnapshotStore implements SnapshotStore {
private readonly byRef = new Map<string, string>()
put(value: unknown): string {
const canonical = canonicalJson(value)
const ref = contentRef(canonical)
if (!this.byRef.has(ref)) this.byRef.set(ref, canonical)
return ref
}
get(ref: string): unknown | undefined {
const canonical = this.byRef.get(ref)
return canonical === undefined ? undefined : JSON.parse(canonical)
}
has(ref: string): boolean {
return this.byRef.has(ref)
}
}
/** Filesystem adapter: one file per ref, write-once. */
export class FsSnapshotStore implements SnapshotStore {
constructor(private readonly dir: string) {
mkdirSync(dir, { recursive: true })
}
private path(ref: string): string {
return join(this.dir, `${ref}.json`)
}
put(value: unknown): string {
const canonical = canonicalJson(value)
const ref = contentRef(canonical)
if (!existsSync(this.path(ref))) writeFileSync(this.path(ref), canonical, { flag: 'wx' })
return ref
}
get(ref: string): unknown | undefined {
if (!this.has(ref)) return undefined
return JSON.parse(readFileSync(this.path(ref), 'utf8'))
}
has(ref: string): boolean {
return existsSync(this.path(ref))
}
}

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { PositionUpdate } from '@vpp/domain'
import { CascadeViolation, LedgerConcurrencyError, LedgerService } from '../src/ledger.js'
const clock = () => '2026-03-14T08:00:00Z'
const monthlyContract: PositionUpdate = {
id: 'pos-monthly',
timescale: 'MONTHLY',
period: '2026-03',
kind: 'CONTRACT',
energy_mwh: '37200.0', // 1200 MWh/day over 31 days
curve: null,
source_ref: 'contract-2026-03-001',
expected_version: 0,
}
const daBid = (energy: string, expectedVersion: number): PositionUpdate => ({
id: `pos-da-${energy}`,
timescale: 'DAY_AHEAD',
period: '2026-03-15',
kind: 'BID_SUBMITTED',
energy_mwh: energy,
curve: null,
source_ref: 'b'.repeat(64),
expected_version: expectedVersion,
})
const makeLedger = () => new LedgerService({ daMonthlyDeviationBand: '0.05', clock })
describe('constraint cascade (P7): monthly position bounds day-ahead bids', () => {
it('accepts a bid within the ±5% band of the pro-rata daily share', () => {
const ledger = makeLedger()
ledger.append(monthlyContract)
const view = ledger.append(daBid('1240.0', 1)) // daily share 1200, band [1140, 1260]
expect(view.version).toBe(2)
})
it('rejects a bid above the band', () => {
const ledger = makeLedger()
ledger.append(monthlyContract)
expect(() => ledger.append(daBid('1300.0', 1))).toThrow(CascadeViolation)
})
it('rejects a bid below the band', () => {
const ledger = makeLedger()
ledger.append(monthlyContract)
expect(() => ledger.append(daBid('1000.0', 1))).toThrow(CascadeViolation)
})
it('rejects a day-ahead bid with no monthly anchor (no default-allow)', () => {
const ledger = makeLedger()
expect(() => ledger.append(daBid('1200.0', 0))).toThrow(CascadeViolation)
})
})
describe('optimistic concurrency', () => {
it('rejects an append against a stale version', () => {
const ledger = makeLedger()
ledger.append(monthlyContract)
expect(() => ledger.append({ ...monthlyContract, id: 'pos-2', expected_version: 0 })).toThrow(
LedgerConcurrencyError,
)
})
})
describe('views', () => {
it('filters by timescale and stamps recorded_at from the injected clock', () => {
const ledger = makeLedger()
ledger.append(monthlyContract)
ledger.append(daBid('1200.0', 1))
const monthly = ledger.viewByTimescale('MONTHLY')
expect(monthly).toHaveLength(1)
expect(monthly[0]!.recorded_at).toBe(clock())
expect(ledger.viewByTimescale('DAY_AHEAD')).toHaveLength(1)
})
})

View File

@ -0,0 +1,39 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { FsSnapshotStore, MemorySnapshotStore, canonicalJson, contentRef } from '../src/snapshot.js'
describe.each([
['memory', () => new MemorySnapshotStore()],
['fs', () => new FsSnapshotStore(mkdtempSync(join(tmpdir(), 'vpp-snap-')))],
])('snapshot store (%s)', (_label, make) => {
it('is content-addressed: same content → same ref, regardless of key order', () => {
const store = make()
const ref1 = store.put({ b: '2', a: '1' })
const ref2 = store.put({ a: '1', b: '2' })
expect(ref1).toBe(ref2)
expect(ref1).toMatch(/^[0-9a-f]{64}$/)
})
it('round-trips content and reports presence', () => {
const store = make()
const value = { curve: ['1.0', '2.5'], meta: { unit: 'mw' } }
const ref = store.put(value)
expect(store.has(ref)).toBe(true)
expect(store.get(ref)).toEqual(value)
expect(store.get('f'.repeat(64))).toBeUndefined()
})
it('ref verifies against canonical content (checksummed by construction)', () => {
const store = make()
const value = { x: '42.0' }
const ref = store.put(value)
expect(contentRef(canonicalJson(store.get(ref)))).toBe(ref)
})
it('rejects float content (decimal-string rule, docs/11 §3.3)', () => {
const store = make()
expect(() => store.put({ price: 425.5 })).toThrow(/decimal strings/)
})
})

View File

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

View File

@ -0,0 +1,3 @@
pydantic>=2.7
datamodel-code-generator>=0.26
pytest>=8.0

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Regenerates skills-py/vpp_contracts from contracts/schema (ADR-0002).
# Generated files are committed but NEVER hand-edited.
set -euo pipefail
cd "$(dirname "$0")/.."
datamodel-codegen \
--input ../contracts/schema \
--input-file-type jsonschema \
--output vpp_contracts \
--output-model-type pydantic_v2.BaseModel \
--target-python-version 3.11 \
--use-standard-collections \
--disable-timestamp
echo "models regenerated in skills-py/vpp_contracts"

View File

@ -0,0 +1,37 @@
"""Dual-side contract test (docs/11 §3.2): the same golden fixtures the TS side
validates must validate against the generated pydantic models, and the same
invalid fixtures must be rejected. Disagreement between sides fails CI."""
import importlib
from pathlib import Path
import pytest
from pydantic import BaseModel, ValidationError
ROOT = Path(__file__).resolve().parents[2] / "contracts"
def model_for(name: str) -> type[BaseModel]:
module = importlib.import_module(f"vpp_contracts.{name}")
class_name = "".join(part.capitalize() for part in name.split("_"))
return getattr(module, class_name)
def cases(dirname: str) -> list[tuple[str, Path]]:
out = []
for schema_dir in sorted((ROOT / dirname).iterdir()):
for f in sorted(schema_dir.glob("*.json")):
out.append((schema_dir.name, f))
return out
@pytest.mark.parametrize("name,path", cases("fixtures"), ids=lambda p: str(p))
def test_golden_fixture_validates(name: str, path: Path) -> None:
# JSON-mode validation — exactly how a skill service receives these objects.
model_for(name).model_validate_json(path.read_text())
@pytest.mark.parametrize("name,path", cases("fixtures-invalid"), ids=lambda p: str(p))
def test_invalid_fixture_rejected(name: str, path: Path) -> None:
with pytest.raises(ValidationError):
model_for(name).model_validate_json(path.read_text())

View File

@ -0,0 +1,2 @@
# generated by datamodel-codegen:
# filename: schema

View File

@ -0,0 +1,68 @@
# generated by datamodel-codegen:
# filename: approval.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, conint, constr
class Decision(StrEnum):
APPROVE = 'APPROVE'
REJECT = 'REJECT'
class Approver(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
role: constr(min_length=1)
class EffectType(StrEnum):
BID = 'BID'
INVITATION = 'INVITATION'
CONTROL_PLAN = 'CONTROL_PLAN'
DISPATCH_PLAN = 'DISPATCH_PLAN'
class Scope(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
effect_type: EffectType
limits: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')]
class Validity(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
from_: AwareDatetime = Field(..., alias='from')
to: AwareDatetime
class EvidenceVersions(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
policy_pack_version: constr(min_length=1)
ledger_version: conint(ge=0, le=9007199254740991)
data_snapshot_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
class Approval(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
decision: Decision
approver: Approver
scope: Scope
validity: Validity
evidence_versions: EvidenceVersions
comment: str | None = None
decided_at: AwareDatetime

View File

@ -0,0 +1,51 @@
# generated by datamodel-codegen:
# filename: decision_case.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class Kind(StrEnum):
DAY_AHEAD_BID = 'DAY_AHEAD_BID'
DR_EVENT = 'DR_EVENT'
DEVIATION_RESOLUTION = 'DEVIATION_RESOLUTION'
REVIEW = 'REVIEW'
ADHOC_ANALYSIS = 'ADHOC_ANALYSIS'
class Status(StrEnum):
OPEN = 'OPEN'
IN_PROGRESS = 'IN_PROGRESS'
AWAITING_APPROVAL = 'AWAITING_APPROVAL'
CLOSED_DONE = 'CLOSED_DONE'
CLOSED_ABORTED = 'CLOSED_ABORTED'
class Refs(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
evidence: list[constr(min_length=1)]
proposals: list[constr(min_length=1)]
approvals: list[constr(min_length=1)]
permits: list[constr(min_length=1)]
scenarios: list[constr(min_length=1)]
outcome: constr(min_length=1) | None
class DecisionCase(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
kind: Kind
objective: constr(min_length=1)
owner: constr(min_length=1)
deadline: AwareDatetime
status: Status
refs: Refs
opened_at: AwareDatetime
closed_at: AwareDatetime | None

View File

@ -0,0 +1,93 @@
# generated by datamodel-codegen:
# filename: envelope.json
from __future__ import annotations
from enum import StrEnum
from pydantic import (
AwareDatetime,
BaseModel,
ConfigDict,
Field,
RootModel,
conint,
constr,
)
class ProposalType(StrEnum):
BID = 'BID'
INVITATION = 'INVITATION'
CONTROL_PLAN = 'CONTROL_PLAN'
DISPATCH_PLAN = 'DISPATCH_PLAN'
class Timescale(StrEnum):
ANNUAL = 'ANNUAL'
MONTHLY = 'MONTHLY'
DAY_AHEAD = 'DAY_AHEAD'
INTRADAY = 'INTRADAY'
REALTIME = 'REALTIME'
class Scope(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
proposal_type: ProposalType
timescales: list[Timescale] = Field(..., min_length=1)
resource_set: constr(min_length=1)
class Validity(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
from_: AwareDatetime = Field(..., alias='from')
to: AwareDatetime
class Level(StrEnum):
L1 = 'L1'
L2 = 'L2'
L3 = 'L3'
class ApprovedByItem(RootModel[constr(min_length=1)]):
root: constr(min_length=1)
class Approval(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
level: Level
approved_by: list[ApprovedByItem] = Field(..., min_length=1)
class Escalation(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
max_consecutive_deviations: conint(le=9007199254740991, gt=0)
deviation_threshold_pct: constr(pattern=r'^-?\d+(\.\d+)?$')
class Status(StrEnum):
ACTIVE = 'ACTIVE'
SUSPENDED = 'SUSPENDED'
EXPIRED = 'EXPIRED'
class Envelope(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
scope: Scope
bounds: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')]
validity: Validity
approval: Approval
escalation: Escalation
status: Status

View File

@ -0,0 +1,21 @@
# generated by datamodel-codegen:
# filename: event_envelope.json
from __future__ import annotations
from typing import Any
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class EventEnvelope(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
event_id: constr(min_length=1)
event_type: constr(min_length=1)
schema_version: constr(pattern=r'^\d+\.\d+\.\d+$')
occurred_at: AwareDatetime
causation_id: constr(min_length=1)
correlation_id: constr(min_length=1)
payload: Any

View File

@ -0,0 +1,21 @@
# generated by datamodel-codegen:
# filename: execution_permit.json
from __future__ import annotations
from typing import Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class ExecutionPermit(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
proposal_digest: constr(pattern=r'^[0-9a-f]{64}$')
issued_at: AwareDatetime
expires_at: AwareDatetime
effect_limits: dict[str, constr(pattern=r'^-?\d+(\.\d+)?$')]
revoked_at: AwareDatetime | None
issuer: Literal['authority-service']

View File

@ -0,0 +1,59 @@
# generated by datamodel-codegen:
# filename: flexibility_envelope.json
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
class Level(StrEnum):
SITE = 'SITE'
AGGREGATION_UNIT = 'AGGREGATION_UNIT'
PROVINCE = 'PROVINCE'
class Provider(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
unit_id: constr(min_length=1)
level: Level
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
root: constr(pattern=r'^-?\d+(\.\d+)?$')
class UpMw(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 DownMw(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 FlexibilityEnvelope(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
provider: Provider
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
up_mw: UpMw
down_mw: DownMw
valid_until: AwareDatetime
schema_version: constr(min_length=1)
signature: str | None

View File

@ -0,0 +1,82 @@
# generated by datamodel-codegen:
# filename: forecast_bundle.json
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel, constr
class Kind(StrEnum):
LOAD = 'LOAD'
PV = 'PV'
PRICE = 'PRICE'
class Unit(StrEnum):
mw = 'mw'
yuan_per_mwh = 'yuan_per_mwh'
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
root: constr(pattern=r'^-?\d+(\.\d+)?$')
class P10(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 P50(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 P90(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 Quantiles(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
p10: P10
p50: P50
p90: P90
class Model(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
name: constr(min_length=1)
version: constr(min_length=1)
class ForecastBundle(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
kind: Kind
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
unit: Unit
quantiles: Quantiles
model: Model
features_snapshot_ref: constr(pattern=r'^[0-9a-f]{64}$')
generated_at: AwareDatetime

View File

@ -0,0 +1,67 @@
# generated by datamodel-codegen:
# filename: ledger_view.json
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import (
AwareDatetime,
BaseModel,
ConfigDict,
Field,
RootModel,
conint,
constr,
)
class Timescale(StrEnum):
ANNUAL = 'ANNUAL'
MONTHLY = 'MONTHLY'
DAY_AHEAD = 'DAY_AHEAD'
INTRADAY = 'INTRADAY'
REALTIME = 'REALTIME'
class Kind(StrEnum):
CONTRACT = 'CONTRACT'
BID_SUBMITTED = 'BID_SUBMITTED'
AWARD = 'AWARD'
EXTERNAL_COMMITMENT = 'EXTERNAL_COMMITMENT'
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
root: constr(pattern=r'^-?\d+(\.\d+)?$')
class Curve(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 Entry(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
timescale: Timescale
period: constr(pattern=r'^\d{4}(-\d{2}(-\d{2})?)?$')
kind: Kind
energy_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
curve: Curve | None
source_ref: constr(min_length=1)
recorded_at: AwareDatetime
class LedgerView(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
version: conint(ge=0, le=9007199254740991)
entries: list[Entry]

View File

@ -0,0 +1,51 @@
# generated by datamodel-codegen:
# filename: position_update.json
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, RootModel, conint, constr
class Timescale(StrEnum):
ANNUAL = 'ANNUAL'
MONTHLY = 'MONTHLY'
DAY_AHEAD = 'DAY_AHEAD'
INTRADAY = 'INTRADAY'
REALTIME = 'REALTIME'
class Kind(StrEnum):
CONTRACT = 'CONTRACT'
BID_SUBMITTED = 'BID_SUBMITTED'
AWARD = 'AWARD'
EXTERNAL_COMMITMENT = 'EXTERNAL_COMMITMENT'
class Value(RootModel[constr(pattern=r'^-?\d+(\.\d+)?$')]):
root: constr(pattern=r'^-?\d+(\.\d+)?$')
class Curve(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 PositionUpdate(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
timescale: Timescale
period: constr(pattern=r'^\d{4}(-\d{2}(-\d{2})?)?$')
kind: Kind
energy_mwh: constr(pattern=r'^-?\d+(\.\d+)?$')
curve: Curve | None
source_ref: constr(min_length=1)
expected_version: conint(ge=0, le=9007199254740991)

View File

@ -0,0 +1,132 @@
# generated by datamodel-codegen:
# filename: proposal.json
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import (
AwareDatetime,
BaseModel,
ConfigDict,
Field,
RootModel,
conint,
constr,
)
class Timescale(StrEnum):
ANNUAL = 'ANNUAL'
MONTHLY = 'MONTHLY'
DAY_AHEAD = 'DAY_AHEAD'
INTRADAY = 'INTRADAY'
REALTIME = 'REALTIME'
class Trigger(StrEnum):
MANUAL = 'MANUAL'
EVENT = 'EVENT'
SCHEDULED = 'SCHEDULED'
class Originator(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
agent: constr(min_length=1)
trigger: Trigger
task_id: constr(min_length=1)
class ToolCall(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
tool_call_id: constr(min_length=1)
tool: constr(min_length=1)
version: constr(min_length=1)
inputs_ref: constr(pattern=r'^[0-9a-f]{64}$')
outputs_ref: constr(pattern=r'^[0-9a-f]{64}$')
class Lineage(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
tool_calls: list[ToolCall]
data_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
ledger_version: conint(ge=0, le=9007199254740991)
policy_pack_version: constr(min_length=1)
class Status(StrEnum):
DRAFT = 'DRAFT'
RULE_CHECK = 'RULE_CHECK'
SIMULATION = 'SIMULATION'
ENVELOPE_CHECK = 'ENVELOPE_CHECK'
PENDING_HUMAN = 'PENDING_HUMAN'
AUTO_APPROVED = 'AUTO_APPROVED'
APPROVED = 'APPROVED'
FRESH_CHECK = 'FRESH_CHECK'
STALE = 'STALE'
AUTHORIZED = 'AUTHORIZED'
RELEASED = 'RELEASED'
EXECUTING = 'EXECUTING'
COMPLETED = 'COMPLETED'
ROLLED_BACK = 'ROLLED_BACK'
REJECTED = 'REJECTED'
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 Payload(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
kind: Literal['BID']
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
prices_yuan_per_mwh: PricesYuanPerMwh
quantities_mwh: QuantitiesMwh
expected_revenue_yuan: constr(pattern=r'^-?\d+(\.\d+)?$')
class Proposal1(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
timescale: Timescale
originator: Originator
lineage: Lineage
envelope_ref: constr(min_length=1) | None
digest: constr(pattern=r'^[0-9a-f]{64}$')
status: Status
created_at: AwareDatetime
type: Literal['BID']
payload: Payload
class Proposal(RootModel[Proposal1]):
root: Proposal1 = Field(..., title='Proposal')

View File

@ -0,0 +1,42 @@
# generated by datamodel-codegen:
# filename: resource_profile.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, conint, constr
class Type(StrEnum):
STORAGE = 'STORAGE'
PV = 'PV'
EV_CHARGER = 'EV_CHARGER'
HVAC = 'HVAC'
INDUSTRIAL_LOAD = 'INDUSTRIAL_LOAD'
MICROGRID = 'MICROGRID'
BASE_STATION = 'BASE_STATION'
class Constraints(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
min_duration_min: conint(le=9007199254740991, gt=0) | None
recovery_rate_mw_per_min: constr(pattern=r'^-?\d+(\.\d+)?$') | None
class ResourceProfile(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
resource_id: constr(min_length=1)
name: constr(min_length=1)
type: Type
rated_power_mw: constr(pattern=r'^-?\d+(\.\d+)?$')
certified_adjustable_mw: constr(pattern=r'^-?\d+(\.\d+)?$')
confidence: constr(pattern=r'^-?\d+(\.\d+)?$')
reliability_score: constr(pattern=r'^-?\d+(\.\d+)?$')
constraints: Constraints
evidence_refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
updated_at: AwareDatetime

View File

@ -0,0 +1,43 @@
# generated by datamodel-codegen:
# filename: situation_report.json
from __future__ import annotations
from enum import StrEnum
from pydantic import AwareDatetime, BaseModel, ConfigDict, constr
class RiskLevel(StrEnum):
LOW = 'LOW'
MEDIUM = 'MEDIUM'
HIGH = 'HIGH'
EXTREME = 'EXTREME'
class Kind(StrEnum):
TREND = 'TREND'
ANOMALY = 'ANOMALY'
RISK = 'RISK'
ATTRIBUTION = 'ATTRIBUTION'
class Finding(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
kind: Kind
summary: constr(min_length=1)
refs: list[constr(pattern=r'^[0-9a-f]{64}$')]
class SituationReport(BaseModel):
model_config = ConfigDict(
extra='forbid',
)
id: constr(min_length=1)
market_date: constr(pattern=r'^\d{4}-\d{2}-\d{2}$')
risk_level: RiskLevel
findings: list[Finding]
forecast_refs: list[constr(min_length=1)]
generated_at: AwareDatetime

15
tsconfig.base.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"verbatimModuleSyntax": true
}
}