From 478a6db7db580d3189d642ab6fbfc47b3b88a56f Mon Sep 17 00:00:00 2001 From: agent-54de0bcc6a17828b Date: Fri, 24 Apr 2026 21:07:06 +0200 Subject: [PATCH] build(agent): semicolon#54de0b iteration --- README.md | 19 ++-- pyproject.toml | 2 +- src/energysphere/__init__.py | 43 ++++++-- src/energysphere/adapters/der_aggregator.py | 12 +++ src/energysphere/adapters/substation_meter.py | 15 ++- src/energysphere/compat.py | 9 ++ src/energysphere/energi_bridge.py | 5 + src/energysphere/governance.py | 101 ++++++++++++++++++ src/energysphere/models.py | 55 +++++++++- src/energysphere/registry.py | 78 +++++++++++++- src/energysphere/server/main.py | 53 ++++++++- src/energysphere/sync.py | 63 +++++++++++ test.sh | 2 +- tests/test_governance_sync_api.py | 54 ++++++++++ 14 files changed, 485 insertions(+), 26 deletions(-) create mode 100644 src/energysphere/compat.py create mode 100644 src/energysphere/governance.py create mode 100644 src/energysphere/sync.py create mode 100644 tests/test_governance_sync_api.py diff --git a/README.md b/README.md index fee2abe..66704b2 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,27 @@ OpenEnergySphere EnergiBridge MVP Overview - EnergiBridge provides a canonical, vendor-agnostic interoperability layer for cross-domain energy planning. It translates domain primitives into a compact IR (Object, SharedVariables, PlanDelta) and enables plug-and-play adapters across DERs, meters, pumps, and buildings while preserving offline-first operation and governance hooks. +- The current codebase includes a signed governance ledger, deterministic delta-sync, a contract template registry, a lightweight marketplace listing flow, and optional SQLite persistence for the registry. Architecture -- Core primitives: LocalProblem (per-site optimization), SharedVariables (signals and priors), PlanDelta (incremental actions with provenance), and a Graph-of-Contracts (GoC) registry for adapters and data schemas. +- Core primitives: LocalProblem (per-site optimization), SharedVariables (signals and priors), PlanDelta (incremental actions with provenance), DeltaEnvelope (offline sync packaging), and a Graph-of-Contracts registry for adapters, templates, and data schemas. +- Governance: GovernanceLedger produces chained, HMAC-signed audit entries for approvals and policy changes. - EnergiBridgeMapper (in src/energysphere/energi_bridge.py) maps Energysphere primitives to a canonical IR representation and supports round-tripping LocalProblem objects. -- Registry: In-memory GraphContractRegistry with metadata for auditing and replay protection. LocalProblems get stored with version, timestamp, and nonce metadata. -- Adapters: Starter adapters implemented under energysphere/adapters/ (SubstationMeterAdapter, DERAggregatorAdapter). -- Server API: Lightweight FastAPI app in energysphere/server/main.py exposing contract and adapter endpoints. +- Registry: GraphContractRegistry supports in-memory operation and optional SQLite-backed persistence. LocalProblems get stored with version, timestamp, and nonce metadata. +- Adapters: Starter adapters implemented under energysphere/adapters/ (SubstationMeterAdapter, DERAggregatorAdapter) and marketplace capability publishing. +- Server API: FastAPI app in energysphere/server/main.py exposing contract, template, delta-sync, and governance endpoints. -Roadmap (MVP, 8–12 weeks) -- Phase 0: Protocol skeleton, 2 starter adapters, TLS transport, ADMM-lite local solver, delta-sync with deterministic replay. +Roadmap +- Phase 0: Protocol skeleton, 2 starter adapters, TLS transport, delta-sync with deterministic replay. - Phase 1: Governance ledger scaffold, identity management (DIDs/certs), secure aggregation for SharedVariables. - Phase 2: Cross-domain demo with toy cross-domain objective and EnergiBridge SDK. - Phase 3: Hardware-in-the-loop validation and KPI dashboards. Data Contracts Seeds -- LocalProblem, SharedVariables, DualVariables, PlanDelta, PrivacyBudget, AuditLog; a Graph-of-Contracts registry; minimal contract example and conformance harness for adapters. +- LocalProblem, SharedVariables, DualVariables, PlanDelta, DeltaEnvelope, ContractTemplate, GovernanceEvent, PrivacyBudget, AuditLog; a Graph-of-Contracts registry; minimal contract example and conformance harness for adapters. Security & Governance -- Hardware-backed attestation for adapters; DID/short-lived certs; attestation for plan deltas; optional local DP budgets. +- Hardware-backed attestation for adapters; DID/short-lived certs; attestation for plan deltas; optional local DP budgets; append-only governance audit chain. How to run (current MVP) - Install dependencies and run tests: @@ -31,3 +33,4 @@ How to run (current MVP) Notes - This project uses a pyproject.toml with a proper build-system. Packaging metadata is wired to README.md via readme = "README.md" in pyproject.toml. - The goal is to keep a tight MVP surface while enabling gradual expansion into a production-grade, privacy-preserving federation platform. +- Optional SQLite persistence can be enabled by constructing `GraphContractRegistry(db_path=...)`. diff --git a/pyproject.toml b/pyproject.toml index c7427dd..39a043b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.10" dependencies = [ "fastapi>=0.90.0", + "httpx>=0.23.0", "uvicorn[standard]>=0.18.0", "pydantic>=1.10.2", "pytest>=7.0.0" @@ -18,4 +19,3 @@ dependencies = [ [tool.setuptools] package-dir = { "" = "src" } - diff --git a/src/energysphere/__init__.py b/src/energysphere/__init__.py index c6fa6ba..827135a 100644 --- a/src/energysphere/__init__.py +++ b/src/energysphere/__init__.py @@ -1,9 +1,38 @@ -""" Energysphere core package minimal init. """ +"""EnergiBridge core package.""" __version__ = "0.1.0" -# Re-export common bridge utilities for quick access -try: - from .energi_bridge import EnergiBridgeMapper # type: ignore -except Exception: - # If optional or during early import stages, fail gracefully. - EnergiBridgeMapper = None # type: ignore +from .energi_bridge import EnergiBridgeMapper +from .governance import GovernanceLedger +from .models import ( + AdapterConformance, + AuditLog, + ContractTemplate, + DeltaEnvelope, + DualVariables, + GovernanceEvent, + LocalProblem, + PlanDelta, + PrivacyBudget, + SharedVariable, + SharedVariables, +) +from .registry import GraphContractRegistry +from .sync import DeltaSyncEngine + +__all__ = [ + "AdapterConformance", + "AuditLog", + "ContractTemplate", + "DeltaEnvelope", + "DeltaSyncEngine", + "DualVariables", + "EnergiBridgeMapper", + "GovernanceEvent", + "GovernanceLedger", + "GraphContractRegistry", + "LocalProblem", + "PlanDelta", + "PrivacyBudget", + "SharedVariable", + "SharedVariables", +] diff --git a/src/energysphere/adapters/der_aggregator.py b/src/energysphere/adapters/der_aggregator.py index e0b1b15..15980e1 100644 --- a/src/energysphere/adapters/der_aggregator.py +++ b/src/energysphere/adapters/der_aggregator.py @@ -9,12 +9,24 @@ class DERAggregatorAdapter: def __init__(self, registry: GraphContractRegistry) -> None: self._registry = registry + self.supported_domains = ["electricity", "distributed-energy-resources"] def collect_local_problem(self) -> LocalProblem: lp = LocalProblem( site_id="site-der-agg-001", objective="min-carbon", parameters={"der_count": 5, "time_horizon": 24}, + domain="electricity", ) self._registry.register_local_problem("contract-der-aggregator-001", lp) return lp + + def publish_capability(self) -> dict[str, object]: + listing = { + "adapter_id": self.adapter_id, + "name": "DER Aggregator Adapter", + "domains": self.supported_domains, + "contracts": ["contract-der-aggregator-001"], + } + self._registry.register_marketplace_entry(self.adapter_id, listing) + return listing diff --git a/src/energysphere/adapters/substation_meter.py b/src/energysphere/adapters/substation_meter.py index 14e5283..25a682b 100644 --- a/src/energysphere/adapters/substation_meter.py +++ b/src/energysphere/adapters/substation_meter.py @@ -1,10 +1,7 @@ from __future__ import annotations -from typing import Any, Dict - from energysphere.models import LocalProblem from energysphere.registry import GraphContractRegistry -from energysphere.models import LocalProblem class SubstationMeterAdapter: @@ -14,6 +11,7 @@ class SubstationMeterAdapter: def __init__(self, registry: GraphContractRegistry) -> None: self._registry = registry + self.supported_domains = ["electricity"] def collect_local_problem(self) -> LocalProblem: # Minimal sample LocalProblem; in a real system this would read from meters. @@ -21,7 +19,18 @@ class SubstationMeterAdapter: site_id="site-meter-001", objective="min-cost", parameters={"load": 1.0, "time_horizon": 24}, + domain="electricity", ) # register a reference contract for this adapter's generated problem self._registry.register_local_problem("contract-substation-meter-001", lp) return lp + + def publish_capability(self) -> dict[str, object]: + listing = { + "adapter_id": self.adapter_id, + "name": "Substation Meter Adapter", + "domains": self.supported_domains, + "contracts": ["contract-substation-meter-001"], + } + self._registry.register_marketplace_entry(self.adapter_id, listing) + return listing diff --git a/src/energysphere/compat.py b/src/energysphere/compat.py new file mode 100644 index 0000000..d1961a2 --- /dev/null +++ b/src/energysphere/compat.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any + + +def model_to_dict(model: Any) -> dict[str, Any]: + if hasattr(model, "model_dump"): + return model.model_dump() + return model.dict() diff --git a/src/energysphere/energi_bridge.py b/src/energysphere/energi_bridge.py index 0cb0b18..f8daf4d 100644 --- a/src/energysphere/energi_bridge.py +++ b/src/energysphere/energi_bridge.py @@ -60,6 +60,7 @@ class EnergiBridgeMapper: "value": getattr(sv, "value", None), "version": getattr(sv, "version", 1), "privacy_bound": getattr(sv, "privacy_bound", None), + "source_contract_id": getattr(sv, "source_contract_id", None), } def map_plan_delta(self, delta: Any) -> Dict[str, Any]: # be resilient to different shapes @@ -67,10 +68,14 @@ class EnergiBridgeMapper: return { "type": "PlanDelta", "delta_id": getattr(delta, "delta_id", ""), + "contract_id": getattr(delta, "contract_id", None), "changes": getattr(delta, "changes", {}), "timestamp": getattr(delta, "timestamp", 0.0), # Prefer an explicit author field if present; otherwise fall back to delta_id "author": getattr(delta, "author", getattr(delta, "delta_id", "")), + "provenance": getattr(delta, "provenance", {}), + "privacy_budget": getattr(delta, "privacy_budget", None), + "signature": getattr(delta, "signature", None), } diff --git a/src/energysphere/governance.py b/src/energysphere/governance.py new file mode 100644 index 0000000..8684f8b --- /dev/null +++ b/src/energysphere/governance.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +import time +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class LedgerEntry(BaseModel): + event_id: str + event_type: str + actor: str + timestamp: float + payload: Dict[str, Any] = Field(default_factory=dict) + previous_hash: Optional[str] = None + entry_hash: str + signature: str + + +class GovernanceLedger: + """Append-only governance ledger with chained hashes and HMAC signatures.""" + + def __init__(self, signing_secret: str | None = None) -> None: + self._signing_secret = signing_secret or secrets.token_hex(32) + self._entries: List[LedgerEntry] = [] + + @property + def signing_secret(self) -> str: + return self._signing_secret + + def append_event( + self, + event_type: str, + actor: str, + payload: Dict[str, Any] | None = None, + *, + timestamp: float | None = None, + event_id: str | None = None, + ) -> LedgerEntry: + payload = payload or {} + timestamp = time.time() if timestamp is None else timestamp + event_id = event_id or secrets.token_hex(12) + previous_hash = self._entries[-1].entry_hash if self._entries else None + canonical = { + "event_id": event_id, + "event_type": event_type, + "actor": actor, + "timestamp": timestamp, + "payload": payload, + "previous_hash": previous_hash, + } + entry_hash = self._hash(canonical) + signature = self._sign(entry_hash) + entry = LedgerEntry( + event_id=event_id, + event_type=event_type, + actor=actor, + timestamp=timestamp, + payload=payload, + previous_hash=previous_hash, + entry_hash=entry_hash, + signature=signature, + ) + self._entries.append(entry) + return entry + + def list_entries(self) -> List[LedgerEntry]: + return list(self._entries) + + def verify_chain(self) -> bool: + previous_hash = None + for entry in self._entries: + canonical = { + "event_id": entry.event_id, + "event_type": entry.event_type, + "actor": entry.actor, + "timestamp": entry.timestamp, + "payload": entry.payload, + "previous_hash": previous_hash, + } + expected_hash = self._hash(canonical) + if expected_hash != entry.entry_hash: + return False + if self._sign(expected_hash) != entry.signature: + return False + previous_hash = entry.entry_hash + return True + + def _hash(self, payload: Dict[str, Any]) -> str: + blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(blob).hexdigest() + + def _sign(self, entry_hash: str) -> str: + return hmac.new(self._signing_secret.encode("utf-8"), entry_hash.encode("utf-8"), hashlib.sha256).hexdigest() + + +__all__ = ["GovernanceLedger", "LedgerEntry"] diff --git a/src/energysphere/models.py b/src/energysphere/models.py index 880f45c..940f458 100644 --- a/src/energysphere/models.py +++ b/src/energysphere/models.py @@ -1,13 +1,16 @@ from __future__ import annotations -from typing import Any, Dict, Optional -from pydantic import BaseModel +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field class LocalProblem(BaseModel): site_id: str objective: str parameters: Dict[str, Any] + domain: str = "electricity" + contract_id: Optional[str] = None class SharedVariables(BaseModel): @@ -15,22 +18,41 @@ class SharedVariables(BaseModel): value: Any version: int privacy_bound: Optional[float] = None + source_contract_id: Optional[str] = None + + +SharedVariable = SharedVariables class DualVariables(BaseModel): primal: float dual: float + step: Optional[int] = None class PlanDelta(BaseModel): delta_id: str + contract_id: Optional[str] = None changes: Dict[str, Any] timestamp: float + author: Optional[str] = None + provenance: Dict[str, Any] = Field(default_factory=dict) + privacy_budget: Optional[float] = None + signature: Optional[str] = None + + +class DeltaEnvelope(BaseModel): + replica_id: str + sequence: int + plan_delta: PlanDelta + previous_hash: Optional[str] = None + entry_hash: Optional[str] = None class PrivacyBudget(BaseModel): budget: float used: float + scope: Optional[str] = None class AuditLog(BaseModel): @@ -38,3 +60,32 @@ class AuditLog(BaseModel): action: str timestamp: float actor: str + result: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class ContractTemplate(BaseModel): + template_id: str + name: str + domain: str + schema_version: int = 1 + description: Optional[str] = None + fields: Dict[str, str] = Field(default_factory=dict) + + +class GovernanceEvent(BaseModel): + event_id: str + event_type: str + actor: str + timestamp: float + payload: Dict[str, Any] = Field(default_factory=dict) + previous_hash: Optional[str] = None + entry_hash: Optional[str] = None + signature: Optional[str] = None + + +class AdapterConformance(BaseModel): + adapter_id: str + contract_id: str + status: str + notes: List[str] = Field(default_factory=list) diff --git a/src/energysphere/registry.py b/src/energysphere/registry.py index c66f827..71b19bd 100644 --- a/src/energysphere/registry.py +++ b/src/energysphere/registry.py @@ -1,10 +1,14 @@ from __future__ import annotations +import json +import sqlite3 import time import secrets +from pathlib import Path from typing import Any, Dict, List -from .models import LocalProblem +from .compat import model_to_dict +from .models import AdapterConformance, ContractTemplate, LocalProblem, PlanDelta class GraphContractRegistry: @@ -14,8 +18,16 @@ class GraphContractRegistry: a persistent registry backed by SQLite/PostgreSQL in the future. """ - def __init__(self) -> None: + def __init__(self, db_path: str | None = None) -> None: self._contracts: Dict[str, Dict[str, Any]] = {} + self._templates: Dict[str, Dict[str, Any]] = {} + self._deltas: Dict[str, Dict[str, Any]] = {} + self._marketplace: Dict[str, Dict[str, Any]] = {} + self._db_path = Path(db_path) if db_path else None + self._conn = sqlite3.connect(self._db_path) if self._db_path else None + if self._conn is not None: + self._conn.row_factory = sqlite3.Row + self._init_db() def register_contract(self, contract_id: str, contract_spec: Dict[str, Any]) -> None: """Register a contract specification with optional metadata. @@ -25,6 +37,7 @@ class GraphContractRegistry: contract_spec as-is for backward-compatibility. """ self._contracts[contract_id] = contract_spec + self._persist("contracts", contract_id, contract_spec) def register_contract_with_meta( self, @@ -49,6 +62,7 @@ class GraphContractRegistry: contract_spec = dict(contract_spec) # shallow copy contract_spec["_meta"] = meta self._contracts[contract_id] = contract_spec + self._persist("contracts", contract_id, contract_spec) def list_contracts(self) -> List[Dict[str, Any]]: return list(self._contracts.values()) @@ -56,13 +70,71 @@ class GraphContractRegistry: def get_contract(self, contract_id: str) -> Dict[str, Any] | None: return self._contracts.get(contract_id) + def register_contract_template(self, template_id: str, template: ContractTemplate | Dict[str, Any]) -> None: + template_dict = model_to_dict(template) if isinstance(template, ContractTemplate) else dict(template) + self._templates[template_id] = template_dict + self._persist("templates", template_id, template_dict) + + def list_contract_templates(self) -> List[Dict[str, Any]]: + return list(self._templates.values()) + + def register_plan_delta(self, delta: PlanDelta) -> None: + delta_dict = model_to_dict(delta) + self._deltas[delta.delta_id] = delta_dict + self._persist("deltas", delta.delta_id, delta_dict) + + def list_plan_deltas(self) -> List[Dict[str, Any]]: + return list(self._deltas.values()) + + def register_marketplace_entry(self, entry_id: str, entry: Dict[str, Any]) -> None: + self._marketplace[entry_id] = dict(entry) + self._persist("marketplace", entry_id, entry) + + def list_marketplace_entries(self) -> List[Dict[str, Any]]: + return list(self._marketplace.values()) + + def conformance_report(self, adapter_id: str, contract_ids: List[str]) -> AdapterConformance: + missing = [contract_id for contract_id in contract_ids if contract_id not in self._contracts] + status = "pass" if not missing else "partial" + notes = [f"missing:{contract_id}" for contract_id in missing] + return AdapterConformance(adapter_id=adapter_id, contract_id=",".join(contract_ids), status=status, notes=notes) + # Lightweight helper to store a LocalProblem as a contract reference def register_local_problem(self, contract_id: str, lp: LocalProblem) -> None: """Register a LocalProblem with default metadata.""" - spec = lp.dict() + spec = model_to_dict(lp) spec["_meta"] = { "version": 1, "timestamp": time.time(), "nonce": secrets.token_hex(8), } self.register_contract(contract_id, spec) + + def _init_db(self) -> None: + assert self._conn is not None + self._conn.execute( + "CREATE TABLE IF NOT EXISTS registry_entries (bucket TEXT NOT NULL, entry_id TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (bucket, entry_id))" + ) + self._conn.commit() + cursor = self._conn.execute("SELECT bucket, entry_id, payload FROM registry_entries") + for row in cursor: + payload = json.loads(row["payload"]) + bucket = row["bucket"] + entry_id = row["entry_id"] + if bucket == "contracts": + self._contracts[entry_id] = payload + elif bucket == "templates": + self._templates[entry_id] = payload + elif bucket == "deltas": + self._deltas[entry_id] = payload + elif bucket == "marketplace": + self._marketplace[entry_id] = payload + + def _persist(self, bucket: str, entry_id: str, payload: Dict[str, Any]) -> None: + if self._conn is None: + return + self._conn.execute( + "INSERT OR REPLACE INTO registry_entries(bucket, entry_id, payload) VALUES (?, ?, ?)", + (bucket, entry_id, json.dumps(payload, sort_keys=True)), + ) + self._conn.commit() diff --git a/src/energysphere/server/main.py b/src/energysphere/server/main.py index 2a1200a..1ff7eab 100644 --- a/src/energysphere/server/main.py +++ b/src/energysphere/server/main.py @@ -3,14 +3,19 @@ from __future__ import annotations from fastapi import FastAPI from pydantic import BaseModel +from energysphere.compat import model_to_dict +from energysphere.governance import GovernanceLedger +from energysphere.models import ContractTemplate, LocalProblem, PlanDelta from energysphere.registry import GraphContractRegistry -from energysphere.models import LocalProblem +from energysphere.sync import DeltaSyncEngine app = FastAPI(title="EnergiBridge MVP API") # Simple in-memory stores registry = GraphContractRegistry() adapters_registry: dict[str, dict[str, str]] = {} +ledger = GovernanceLedger() +sync_engine = DeltaSyncEngine(replica_id="api") class LocalProblemDTO(BaseModel): @@ -41,3 +46,49 @@ class AdapterDTO(BaseModel): def register_adapter(dto: AdapterDTO): adapters_registry[dto.adapter_id] = {"description": dto.description or ""} return {"registered": True, "adapter_id": dto.adapter_id} + + +@app.post("/contracts/templates") +def register_contract_template(template: ContractTemplate): + registry.register_contract_template(template.template_id, template) + return {"registered": True, "template_id": template.template_id} + + +@app.get("/contracts/templates") +def list_contract_templates(): + return registry.list_contract_templates() + + +@app.post("/deltas") +def register_plan_delta(delta: PlanDelta): + registry.register_plan_delta(delta) + envelope = sync_engine.issue_delta(delta) + return {"registered": True, "delta": model_to_dict(delta), "envelope": model_to_dict(envelope)} + + +class DeltaBatchDTO(BaseModel): + deltas: list[PlanDelta] + + +@app.post("/sync/reconcile") +def reconcile_deltas(batch: DeltaBatchDTO): + envelopes = [sync_engine.issue_delta(delta) for delta in batch.deltas] + merged = sync_engine.reconcile(envelopes) + return {"merged": [model_to_dict(entry) for entry in merged]} + + +class LedgerEventDTO(BaseModel): + event_type: str + actor: str + payload: dict[str, object] | None = None + + +@app.post("/governance/events") +def append_governance_event(event: LedgerEventDTO): + entry = ledger.append_event(event.event_type, event.actor, event.payload) + return {"entry": model_to_dict(entry), "valid": ledger.verify_chain()} + + +@app.get("/governance/events") +def list_governance_events(): + return [model_to_dict(entry) for entry in ledger.list_entries()] diff --git a/src/energysphere/sync.py b/src/energysphere/sync.py new file mode 100644 index 0000000..f77ee12 --- /dev/null +++ b/src/energysphere/sync.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Iterable, List + +from .compat import model_to_dict +from .models import DeltaEnvelope, PlanDelta + + +class DeltaSyncEngine: + """Deterministic reconciliation for offline-first plan deltas.""" + + def __init__(self, replica_id: str) -> None: + self.replica_id = replica_id + self._sequence = 0 + self._history: list[DeltaEnvelope] = [] + + def issue_delta(self, plan_delta: PlanDelta, previous_hash: str | None = None) -> DeltaEnvelope: + self._sequence += 1 + envelope = DeltaEnvelope( + replica_id=self.replica_id, + sequence=self._sequence, + plan_delta=plan_delta, + previous_hash=previous_hash, + ) + envelope.entry_hash = self._hash_envelope(envelope) + self._history.append(envelope) + return envelope + + def reconcile(self, envelopes: Iterable[DeltaEnvelope]) -> list[DeltaEnvelope]: + merged: dict[str, DeltaEnvelope] = {} + for envelope in envelopes: + key = envelope.plan_delta.delta_id + existing = merged.get(key) + if existing is None or self._rank(envelope) > self._rank(existing): + if envelope.entry_hash is None: + envelope.entry_hash = self._hash_envelope(envelope) + merged[key] = envelope + return sorted(merged.values(), key=self._rank) + + def history(self) -> list[DeltaEnvelope]: + return list(self._history) + + def _rank(self, envelope: DeltaEnvelope) -> tuple[int, float, str]: + return ( + envelope.sequence, + envelope.plan_delta.timestamp, + envelope.plan_delta.delta_id, + ) + + def _hash_envelope(self, envelope: DeltaEnvelope) -> str: + payload = { + "replica_id": envelope.replica_id, + "sequence": envelope.sequence, + "plan_delta": model_to_dict(envelope.plan_delta), + "previous_hash": envelope.previous_hash, + } + blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(blob).hexdigest() + + +__all__ = ["DeltaSyncEngine"] diff --git a/test.sh b/test.sh index 61d3672..1ca4e0c 100644 --- a/test.sh +++ b/test.sh @@ -3,7 +3,7 @@ set -euo pipefail export PYTHONPATH=./src echo "Installing dependencies..." pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true -pip install "pydantic>=1.10.2" "fastapi>=0.90.0" "uvicorn[standard]>=0.18.0" "pytest>=7.0.0" >/dev/null 2>&1 || true +pip install "pydantic>=1.10.2" "fastapi>=0.90.0" "httpx>=0.23.0" "uvicorn[standard]>=0.18.0" "pytest>=7.0.0" >/dev/null 2>&1 || true echo "Running tests..." pytest -q echo "Running packaging build..." diff --git a/tests/test_governance_sync_api.py b/tests/test_governance_sync_api.py new file mode 100644 index 0000000..22fa427 --- /dev/null +++ b/tests/test_governance_sync_api.py @@ -0,0 +1,54 @@ +from energysphere.governance import GovernanceLedger +from energysphere.models import ContractTemplate, PlanDelta +from energysphere.registry import GraphContractRegistry +from energysphere.server.main import app +from energysphere.sync import DeltaSyncEngine + +from fastapi.testclient import TestClient + + +def test_governance_ledger_is_chained_and_verified(): + ledger = GovernanceLedger(signing_secret="secret") + ledger.append_event("policy.approved", "alice", {"policy_id": "p-1"}) + ledger.append_event("policy.updated", "bob", {"policy_id": "p-1"}) + + assert ledger.verify_chain() is True + assert len(ledger.list_entries()) == 2 + + +def test_delta_sync_deduplicates_by_delta_id(): + engine = DeltaSyncEngine(replica_id="site-a") + first = engine.issue_delta(PlanDelta(delta_id="d-1", changes={"a": 1}, timestamp=1.0)) + second = engine.issue_delta(PlanDelta(delta_id="d-1", changes={"a": 2}, timestamp=2.0)) + + merged = engine.reconcile([first, second]) + + assert len(merged) == 1 + assert merged[0].plan_delta.changes == {"a": 2} + + +def test_registry_and_api_support_templates_and_ledger(): + registry = GraphContractRegistry() + template = ContractTemplate( + template_id="tmpl-1", + name="meter-template", + domain="electricity", + fields={"site_id": "string", "load": "number"}, + ) + registry.register_contract_template(template.template_id, template) + report = registry.conformance_report("adapter-1", ["missing-contract"]) + + assert registry.list_contract_templates()[0]["template_id"] == "tmpl-1" + assert report.status == "partial" + assert "missing:missing-contract" in report.notes + + client = TestClient(app) + response = client.post( + "/governance/events", + json={"event_type": "policy.approved", "actor": "alice", "payload": {"policy_id": "p-2"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["valid"] is True + assert body["entry"]["event_type"] == "policy.approved"