build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 21:07:06 +02:00
parent ec94d234be
commit 478a6db7db
14 changed files with 485 additions and 26 deletions

View File

@ -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, 812 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=...)`.

View File

@ -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" }

View File

@ -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",
]

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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),
}

View File

@ -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"]

View File

@ -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)

View File

@ -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()

View File

@ -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()]

63
src/energysphere/sync.py Normal file
View File

@ -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"]

View File

@ -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..."

View File

@ -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"