build(agent): c3po#b883b4 iteration
This commit is contained in:
parent
8a982cecee
commit
f81c910920
|
|
@ -0,0 +1,21 @@
|
||||||
|
node_modules/
|
||||||
|
.npmrc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__tests__/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
READY_TO_PUBLISH
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
CivicPulse is an offline-first coalition coordination core for organizers who need auditability, privacy controls, and deterministic sync across unreliable networks.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Python 3.11+
|
||||||
|
- `pydantic` for schema validation
|
||||||
|
- SQLite via the standard library for local event storage
|
||||||
|
- `pytest` for tests
|
||||||
|
- `build` for packaging verification
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- `models.py`: domain objects for organizations, proposals, endorsements, briefs, shifts, tasks, and provenance records.
|
||||||
|
- `ledger.py`: SQLite event ledger with immutable provenance records and audit-chain retrieval.
|
||||||
|
- `sync.py`: deterministic merge and replay helpers for offline reconciliation.
|
||||||
|
- `privacy.py`: role-based redaction and minimal-signal sharing.
|
||||||
|
- `registry.py`: graph-of-contracts schema and adapter registry.
|
||||||
|
- `service.py`: high-level orchestration API used by tests and CLI.
|
||||||
|
- `cli.py`: small command-line entry point.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- Keep changes deterministic and auditable.
|
||||||
|
- Prefer small, explicit data transformations over hidden magic.
|
||||||
|
- Do not remove provenance records; add new records instead.
|
||||||
|
- Preserve privacy defaults when adding new shareable fields.
|
||||||
|
- Update tests whenever behavior changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `bash test.sh`
|
||||||
|
- `python3 -m pytest -q`
|
||||||
|
- `python3 -m build`
|
||||||
57
README.md
57
README.md
|
|
@ -1,3 +1,56 @@
|
||||||
# idea192-civicpulse-offline-first
|
# CivicPulse
|
||||||
|
|
||||||
Source logic for Idea #192
|
CivicPulse is an offline-first coalition intelligence and policy provenance core for community organizers, unions, tenant groups, and campaign coalitions.
|
||||||
|
|
||||||
|
It focuses on three hard problems:
|
||||||
|
- Deterministic coordination when teams work disconnected.
|
||||||
|
- Auditable provenance for policy drafts, endorsements, and evidence.
|
||||||
|
- Privacy-preserving sharing between organizations.
|
||||||
|
|
||||||
|
## What is implemented
|
||||||
|
|
||||||
|
- SQLite-backed immutable provenance ledger.
|
||||||
|
- Coalition graph objects for organizations, contracts, proposals, endorsements, briefs, tasks, and shifts.
|
||||||
|
- Deterministic sync helpers for replica reconciliation.
|
||||||
|
- Role-based privacy redaction for shared policy briefs.
|
||||||
|
- Graph-of-contracts registry for schemas and adapters.
|
||||||
|
- CSV ingestion adapter for volunteer data.
|
||||||
|
- CLI for audit and brief-sharing inspection.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
- `src/idea192_civicpulse_offline_first/` core package.
|
||||||
|
- `tests/` pytest coverage for provenance, privacy, sync, and replay behavior.
|
||||||
|
- `test.sh` clean verification entrypoint.
|
||||||
|
- `AGENTS.md` contributor guidance for future agents.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pytest -q
|
||||||
|
python3 -m build
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
civicpulse audit <entity_id>
|
||||||
|
civicpulse share-brief <brief_id> <role>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- Events are append-only and hashed for provenance.
|
||||||
|
- Sync uses content-addressed event exchange, so local replicas can converge without a central admin.
|
||||||
|
- Privacy defaults favor redaction, with minimal-signal sharing for public roles.
|
||||||
|
- All stateful actions are recorded in the ledger before being surfaced through the service layer.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
The package name is `idea192_civicpulse_offline_first`, and this README is wired into `pyproject.toml` as the project long description.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "idea192_civicpulse_offline_first"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Offline-first coalition intelligence and policy provenance mesh for community organizers"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [{name = "Community"}]
|
||||||
|
dependencies = ["pydantic>=2.7"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["build>=1.2", "pytest>=8.0"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
civicpulse = "idea192_civicpulse_offline_first.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""CivicPulse offline-first coalition coordination core."""
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Amendment,
|
||||||
|
CoalitionNode,
|
||||||
|
ConsentRule,
|
||||||
|
DeploymentTask,
|
||||||
|
Endorsement,
|
||||||
|
EvidenceAttachment,
|
||||||
|
PolicyBrief,
|
||||||
|
Proposal,
|
||||||
|
RelationshipContract,
|
||||||
|
ShiftAssignment,
|
||||||
|
)
|
||||||
|
from .service import CivicPulseService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Amendment",
|
||||||
|
"CoalitionNode",
|
||||||
|
"ConsentRule",
|
||||||
|
"CivicPulseService",
|
||||||
|
"DeploymentTask",
|
||||||
|
"Endorsement",
|
||||||
|
"EvidenceAttachment",
|
||||||
|
"PolicyBrief",
|
||||||
|
"Proposal",
|
||||||
|
"RelationshipContract",
|
||||||
|
"ShiftAssignment",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .service import CivicPulseService
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(prog="civicpulse")
|
||||||
|
parser.add_argument("--db", default="civicpulse.sqlite3")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
audit = sub.add_parser("audit")
|
||||||
|
audit.add_argument("entity_id")
|
||||||
|
|
||||||
|
brief = sub.add_parser("share-brief")
|
||||||
|
brief.add_argument("brief_id")
|
||||||
|
brief.add_argument("role")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
service = CivicPulseService(Path(args.db))
|
||||||
|
|
||||||
|
if args.command == "audit":
|
||||||
|
for item in service.audit(args.entity_id):
|
||||||
|
print(item)
|
||||||
|
elif args.command == "share-brief":
|
||||||
|
print(service.share_brief(args.brief_id, args.role))
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models import ProvenanceRecord, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value: Any) -> str:
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||||
|
|
||||||
|
|
||||||
|
def digest_record(record: dict[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(canonical_json(record).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class EventLedger:
|
||||||
|
def __init__(self, path: str | Path) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.conn = sqlite3.connect(self.path)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _init_schema(self) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS provenance (
|
||||||
|
hash TEXT PRIMARY KEY,
|
||||||
|
previous_hash TEXT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
actor: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
timestamp: str | None = None,
|
||||||
|
) -> ProvenanceRecord:
|
||||||
|
record = {
|
||||||
|
"previous_hash": self.latest_hash(),
|
||||||
|
"timestamp": timestamp or utc_now(),
|
||||||
|
"actor": actor,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"action": action,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
record_hash = digest_record(record)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO provenance VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
record_hash,
|
||||||
|
record["previous_hash"],
|
||||||
|
record["timestamp"],
|
||||||
|
actor,
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
action,
|
||||||
|
canonical_json(payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return ProvenanceRecord(hash=record_hash, **record)
|
||||||
|
|
||||||
|
def latest_hash(self) -> str | None:
|
||||||
|
row = self.conn.execute("SELECT hash FROM provenance ORDER BY timestamp DESC, hash DESC LIMIT 1").fetchone()
|
||||||
|
return None if row is None else str(row["hash"])
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
row = self.conn.execute("SELECT COUNT(*) AS c FROM provenance").fetchone()
|
||||||
|
return int(row["c"] if row else 0)
|
||||||
|
|
||||||
|
def all(self) -> list[ProvenanceRecord]:
|
||||||
|
rows = self.conn.execute(
|
||||||
|
"SELECT * FROM provenance ORDER BY timestamp ASC, hash ASC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
ProvenanceRecord(
|
||||||
|
hash=row["hash"],
|
||||||
|
previous_hash=row["previous_hash"],
|
||||||
|
timestamp=row["timestamp"],
|
||||||
|
actor=row["actor"],
|
||||||
|
entity_type=row["entity_type"],
|
||||||
|
entity_id=row["entity_id"],
|
||||||
|
action=row["action"],
|
||||||
|
payload=json.loads(row["payload"]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def missing_hashes(self, known_hashes: set[str]) -> list[ProvenanceRecord]:
|
||||||
|
return [record for record in self.all() if record.hash not in known_hashes]
|
||||||
|
|
||||||
|
def import_records(self, records: Iterable[ProvenanceRecord]) -> int:
|
||||||
|
inserted = 0
|
||||||
|
for record in records:
|
||||||
|
before = self.count()
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO provenance VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
record.hash,
|
||||||
|
record.previous_hash,
|
||||||
|
record.timestamp,
|
||||||
|
record.actor,
|
||||||
|
record.entity_type,
|
||||||
|
record.entity_id,
|
||||||
|
record.action,
|
||||||
|
canonical_json(record.payload),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
inserted += self.count() - before
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
def audit_chain(self, entity_id: str) -> list[ProvenanceRecord]:
|
||||||
|
return [record for record in self.all() if record.entity_id == entity_id]
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def new_id(prefix: str) -> str:
|
||||||
|
return f"{prefix}_{uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
class CoalitionNode(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("node"))
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
jurisdiction: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipContract(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("contract"))
|
||||||
|
left_node: str
|
||||||
|
right_node: str
|
||||||
|
relationship: str
|
||||||
|
version: int = 1
|
||||||
|
terms: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentRule(BaseModel):
|
||||||
|
role: str
|
||||||
|
allowed_fields: set[str] = Field(default_factory=set)
|
||||||
|
share_minimal_signal: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalStatus(StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
approved = "approved"
|
||||||
|
rejected = "rejected"
|
||||||
|
withdrawn = "withdrawn"
|
||||||
|
contested = "contested"
|
||||||
|
|
||||||
|
|
||||||
|
class Proposal(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("proposal"))
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
sponsor_org: str
|
||||||
|
status: ProposalStatus = ProposalStatus.pending
|
||||||
|
revisions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Amendment(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("amendment"))
|
||||||
|
proposal_id: str
|
||||||
|
org_id: str
|
||||||
|
body: str
|
||||||
|
accepted: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EndorsementStatus(StrEnum):
|
||||||
|
proposed = "proposed"
|
||||||
|
approved = "approved"
|
||||||
|
declined = "declined"
|
||||||
|
withdrawn = "withdrawn"
|
||||||
|
contested = "contested"
|
||||||
|
|
||||||
|
|
||||||
|
class Endorsement(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("endorsement"))
|
||||||
|
proposal_id: str
|
||||||
|
org_id: str
|
||||||
|
status: EndorsementStatus = EndorsementStatus.proposed
|
||||||
|
rationale: str | None = None
|
||||||
|
signature: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignment(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("shift"))
|
||||||
|
campaign_id: str
|
||||||
|
region: str
|
||||||
|
volunteers: list[str] = Field(default_factory=list)
|
||||||
|
required_count: int = 0
|
||||||
|
status: str = "planned"
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentTask(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("task"))
|
||||||
|
campaign_id: str
|
||||||
|
title: str
|
||||||
|
owner_org: str
|
||||||
|
due_at: str | None = None
|
||||||
|
status: str = "open"
|
||||||
|
fields: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceAttachment(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("evidence"))
|
||||||
|
brief_id: str
|
||||||
|
uri: str
|
||||||
|
digest: str
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyBrief(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: new_id("brief"))
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
source_refs: list[str] = Field(default_factory=list)
|
||||||
|
revision: int = 1
|
||||||
|
supersedes: str | None = None
|
||||||
|
status: str = "draft"
|
||||||
|
|
||||||
|
|
||||||
|
class ProvenanceRecord(BaseModel):
|
||||||
|
hash: str
|
||||||
|
previous_hash: str | None = None
|
||||||
|
timestamp: str
|
||||||
|
actor: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str
|
||||||
|
action: str
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models import ConsentRule
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REDACTIONS = {"address", "email", "phone", "notes", "member_name", "member_id"}
|
||||||
|
|
||||||
|
|
||||||
|
def redact_payload(payload: Mapping[str, Any], rule: ConsentRule | None) -> dict[str, Any]:
|
||||||
|
if rule is None:
|
||||||
|
rule = ConsentRule(role="public")
|
||||||
|
visible = set(rule.allowed_fields)
|
||||||
|
output: dict[str, Any] = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key in visible:
|
||||||
|
output[key] = value
|
||||||
|
elif key in DEFAULT_REDACTIONS:
|
||||||
|
output[key] = "[redacted]"
|
||||||
|
elif rule.share_minimal_signal:
|
||||||
|
if isinstance(value, (int, float, bool)):
|
||||||
|
output[key] = value
|
||||||
|
elif isinstance(value, list):
|
||||||
|
output[key] = len(value)
|
||||||
|
else:
|
||||||
|
output[key] = "[redacted]"
|
||||||
|
else:
|
||||||
|
output[key] = "[redacted]"
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def minimal_signal(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if isinstance(value, (int, float, bool)) or key.endswith("_count")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SchemaDefinition:
|
||||||
|
schema_id: str
|
||||||
|
version: str
|
||||||
|
fields: tuple[str, ...]
|
||||||
|
required: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdapterDefinition:
|
||||||
|
adapter_id: str
|
||||||
|
kind: str
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class GraphRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._schemas: dict[str, SchemaDefinition] = {}
|
||||||
|
self._adapters: dict[str, AdapterDefinition] = {}
|
||||||
|
|
||||||
|
def register_schema(self, schema: SchemaDefinition) -> None:
|
||||||
|
self._schemas[schema.schema_id] = schema
|
||||||
|
|
||||||
|
def register_adapter(self, adapter: AdapterDefinition) -> None:
|
||||||
|
self._adapters[adapter.adapter_id] = adapter
|
||||||
|
|
||||||
|
def describe(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schemas": [schema.__dict__ for schema in self._schemas.values()],
|
||||||
|
"adapters": [adapter.__dict__ for adapter in self._adapters.values()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def csv_rows(text: str) -> list[dict[str, str]]:
|
||||||
|
import csv
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
reader = csv.DictReader(StringIO(text.strip()))
|
||||||
|
return [dict(row) for row in reader]
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .ledger import EventLedger
|
||||||
|
from .models import (
|
||||||
|
Amendment,
|
||||||
|
CoalitionNode,
|
||||||
|
ConsentRule,
|
||||||
|
DeploymentTask,
|
||||||
|
Endorsement,
|
||||||
|
EndorsementStatus,
|
||||||
|
PolicyBrief,
|
||||||
|
Proposal,
|
||||||
|
ProposalStatus,
|
||||||
|
RelationshipContract,
|
||||||
|
ShiftAssignment,
|
||||||
|
)
|
||||||
|
from .privacy import redact_payload
|
||||||
|
from .registry import AdapterDefinition, GraphRegistry, SchemaDefinition, csv_rows
|
||||||
|
from .sync import SyncResult, sync_ledgers
|
||||||
|
|
||||||
|
|
||||||
|
class CivicPulseService:
|
||||||
|
def __init__(self, db_path: str | Path) -> None:
|
||||||
|
self.ledger = EventLedger(db_path)
|
||||||
|
self.registry = GraphRegistry()
|
||||||
|
self.consent_rules: dict[str, ConsentRule] = {
|
||||||
|
"public": ConsentRule(role="public", allowed_fields={"id", "title", "status"}),
|
||||||
|
"org": ConsentRule(role="org", allowed_fields={"id", "title", "status", "revision", "source_refs"}),
|
||||||
|
"admin": ConsentRule(role="admin", allowed_fields={"id", "title", "body", "status", "revision", "source_refs"}),
|
||||||
|
}
|
||||||
|
self.nodes: dict[str, CoalitionNode] = {}
|
||||||
|
self.contracts: dict[str, RelationshipContract] = {}
|
||||||
|
self.proposals: dict[str, Proposal] = {}
|
||||||
|
self.amendments: dict[str, Amendment] = {}
|
||||||
|
self.endorsements: dict[str, Endorsement] = {}
|
||||||
|
self.briefs: dict[str, PolicyBrief] = {}
|
||||||
|
self.tasks: dict[str, DeploymentTask] = {}
|
||||||
|
self.shifts: dict[str, ShiftAssignment] = {}
|
||||||
|
|
||||||
|
def register_schema_defaults(self) -> None:
|
||||||
|
self.registry.register_schema(SchemaDefinition("coalition-node", "1.0", ("id", "kind", "name", "jurisdiction")))
|
||||||
|
self.registry.register_schema(SchemaDefinition("policy-brief", "1.0", ("id", "title", "body", "revision")))
|
||||||
|
self.registry.register_adapter(AdapterDefinition("csv-volunteer-import", "import", {"format": "csv"}))
|
||||||
|
|
||||||
|
def create_org(self, name: str, jurisdiction: str | None = None) -> CoalitionNode:
|
||||||
|
node = CoalitionNode(kind="organization", name=name, jurisdiction=jurisdiction)
|
||||||
|
self.nodes[node.id] = node
|
||||||
|
self.ledger.append("system", "organization", node.id, "create", node.model_dump())
|
||||||
|
return node
|
||||||
|
|
||||||
|
def link_contract(self, left_node: str, right_node: str, relationship: str, terms: dict[str, Any] | None = None) -> RelationshipContract:
|
||||||
|
contract = RelationshipContract(left_node=left_node, right_node=right_node, relationship=relationship, terms=terms or {})
|
||||||
|
self.contracts[contract.id] = contract
|
||||||
|
self.ledger.append("system", "contract", contract.id, "link", contract.model_dump())
|
||||||
|
return contract
|
||||||
|
|
||||||
|
def create_proposal(self, sponsor_org: str, title: str, body: str) -> Proposal:
|
||||||
|
proposal = Proposal(sponsor_org=sponsor_org, title=title, body=body)
|
||||||
|
self.proposals[proposal.id] = proposal
|
||||||
|
self.ledger.append(sponsor_org, "proposal", proposal.id, "create", proposal.model_dump())
|
||||||
|
return proposal
|
||||||
|
|
||||||
|
def add_amendment(self, proposal_id: str, org_id: str, body: str, accepted: bool | None = None) -> Amendment:
|
||||||
|
amendment = Amendment(proposal_id=proposal_id, org_id=org_id, body=body, accepted=accepted)
|
||||||
|
self.amendments[amendment.id] = amendment
|
||||||
|
self.ledger.append(org_id, "amendment", amendment.id, "propose", amendment.model_dump())
|
||||||
|
proposal = self.proposals[proposal_id]
|
||||||
|
proposal.revisions.append(amendment.id)
|
||||||
|
if accepted is True:
|
||||||
|
proposal.status = ProposalStatus.contested if proposal.status == ProposalStatus.rejected else ProposalStatus.approved
|
||||||
|
return amendment
|
||||||
|
|
||||||
|
def decide_endorsement(self, proposal_id: str, org_id: str, approved: bool, rationale: str | None = None) -> Endorsement:
|
||||||
|
endorsement = Endorsement(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
org_id=org_id,
|
||||||
|
status=EndorsementStatus.approved if approved else EndorsementStatus.declined,
|
||||||
|
rationale=rationale,
|
||||||
|
signature=self._signature({"proposal_id": proposal_id, "org_id": org_id, "approved": approved, "rationale": rationale}),
|
||||||
|
)
|
||||||
|
self.endorsements[endorsement.id] = endorsement
|
||||||
|
self.ledger.append(org_id, "endorsement", endorsement.id, "decide", endorsement.model_dump())
|
||||||
|
self._recompute_proposal_status(proposal_id)
|
||||||
|
return endorsement
|
||||||
|
|
||||||
|
def create_policy_brief(self, title: str, body: str, source_refs: list[str] | None = None, supersedes: str | None = None) -> PolicyBrief:
|
||||||
|
brief = PolicyBrief(title=title, body=body, source_refs=source_refs or [], supersedes=supersedes)
|
||||||
|
if supersedes and supersedes in self.briefs:
|
||||||
|
brief.revision = self.briefs[supersedes].revision + 1
|
||||||
|
self.briefs[brief.id] = brief
|
||||||
|
self.ledger.append("policy-office", "policy_brief", brief.id, "publish", brief.model_dump())
|
||||||
|
return brief
|
||||||
|
|
||||||
|
def attach_evidence(self, brief_id: str, uri: str, digest: str, note: str | None = None) -> dict[str, Any]:
|
||||||
|
payload = {"brief_id": brief_id, "uri": uri, "digest": digest, "note": note}
|
||||||
|
self.ledger.append("policy-office", "evidence", brief_id, "attach", payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def allocate_shift(self, campaign_id: str, region: str, volunteers: list[str], required_count: int) -> ShiftAssignment:
|
||||||
|
shift = ShiftAssignment(campaign_id=campaign_id, region=region, volunteers=volunteers, required_count=required_count)
|
||||||
|
self.shifts[shift.id] = shift
|
||||||
|
self.ledger.append("field-team", "shift", shift.id, "allocate", shift.model_dump())
|
||||||
|
return shift
|
||||||
|
|
||||||
|
def create_task(self, campaign_id: str, title: str, owner_org: str, due_at: str | None = None, fields: dict[str, Any] | None = None) -> DeploymentTask:
|
||||||
|
task = DeploymentTask(campaign_id=campaign_id, title=title, owner_org=owner_org, due_at=due_at, fields=fields or {})
|
||||||
|
self.tasks[task.id] = task
|
||||||
|
self.ledger.append(owner_org, "task", task.id, "create", task.model_dump())
|
||||||
|
return task
|
||||||
|
|
||||||
|
def share_brief(self, brief_id: str, role: str) -> dict[str, Any]:
|
||||||
|
brief = self.briefs[brief_id]
|
||||||
|
rule = self.consent_rules.get(role, self.consent_rules["public"])
|
||||||
|
return redact_payload(brief.model_dump(), rule)
|
||||||
|
|
||||||
|
def audit(self, entity_id: str) -> list[dict[str, Any]]:
|
||||||
|
return [record.model_dump() for record in self.ledger.audit_chain(entity_id)]
|
||||||
|
|
||||||
|
def sync_with(self, other: "CivicPulseService") -> SyncResult:
|
||||||
|
return sync_ledgers(self.ledger, other.ledger)
|
||||||
|
|
||||||
|
def ingest_volunteers_csv(self, text: str) -> list[dict[str, str]]:
|
||||||
|
rows = csv_rows(text)
|
||||||
|
self.ledger.append("importer", "adapter", "csv-volunteer-import", "ingest", {"row_count": len(rows)})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _signature(self, payload: dict[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(str(sorted(payload.items())).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _recompute_proposal_status(self, proposal_id: str) -> None:
|
||||||
|
proposal = self.proposals[proposal_id]
|
||||||
|
decisions = [item for item in self.endorsements.values() if item.proposal_id == proposal_id]
|
||||||
|
if any(item.status == EndorsementStatus.contested for item in decisions):
|
||||||
|
proposal.status = ProposalStatus.contested
|
||||||
|
return
|
||||||
|
if decisions and all(item.status == EndorsementStatus.approved for item in decisions):
|
||||||
|
proposal.status = ProposalStatus.approved
|
||||||
|
elif any(item.status == EndorsementStatus.declined for item in decisions):
|
||||||
|
proposal.status = ProposalStatus.rejected
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .ledger import EventLedger
|
||||||
|
from .models import ProvenanceRecord
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyncResult:
|
||||||
|
accepted: int
|
||||||
|
missing_local: int
|
||||||
|
|
||||||
|
|
||||||
|
def deterministic_replay(records: list[ProvenanceRecord]) -> list[ProvenanceRecord]:
|
||||||
|
return sorted(records, key=lambda item: (item.timestamp, item.hash))
|
||||||
|
|
||||||
|
|
||||||
|
def sync_ledgers(local: EventLedger, remote: EventLedger) -> SyncResult:
|
||||||
|
local_hashes = {record.hash for record in local.all()}
|
||||||
|
remote_hashes = {record.hash for record in remote.all()}
|
||||||
|
imported_local = local.import_records(remote.missing_hashes(local_hashes))
|
||||||
|
imported_remote = remote.import_records(local.missing_hashes(remote_hashes))
|
||||||
|
return SyncResult(accepted=imported_local + imported_remote, missing_local=len(local.missing_hashes(remote_hashes)))
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python3 -m pip install -e ".[dev]"
|
||||||
|
python3 -m pytest -q
|
||||||
|
python3 -m build
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from idea192_civicpulse_offline_first.service import CivicPulseService
|
||||||
|
from idea192_civicpulse_offline_first.sync import deterministic_replay
|
||||||
|
|
||||||
|
|
||||||
|
def test_endorsement_and_audit(tmp_path: Path) -> None:
|
||||||
|
service = CivicPulseService(tmp_path / "ledger.sqlite3")
|
||||||
|
org = service.create_org("Tenant Action Network")
|
||||||
|
proposal = service.create_proposal(org.id, "Endorse rent stabilization", "Coalition policy brief")
|
||||||
|
endorsement = service.decide_endorsement(proposal.id, org.id, True, "Aligned with tenant protections")
|
||||||
|
|
||||||
|
audit = service.audit(proposal.id)
|
||||||
|
|
||||||
|
assert proposal.status == "approved"
|
||||||
|
assert endorsement.signature
|
||||||
|
assert any(item["action"] == "create" for item in audit)
|
||||||
|
assert any(item["action"] == "decide" for item in service.audit(endorsement.id))
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_redacts_public_briefs(tmp_path: Path) -> None:
|
||||||
|
service = CivicPulseService(tmp_path / "ledger.sqlite3")
|
||||||
|
brief = service.create_policy_brief("Field brief", "Sensitive analysis", ["source-1"])
|
||||||
|
|
||||||
|
public_view = service.share_brief(brief.id, "public")
|
||||||
|
admin_view = service.share_brief(brief.id, "admin")
|
||||||
|
|
||||||
|
assert public_view["body"] == "[redacted]"
|
||||||
|
assert admin_view["body"] == "Sensitive analysis"
|
||||||
|
assert public_view["revision"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_merges_ledger_events(tmp_path: Path) -> None:
|
||||||
|
left = CivicPulseService(tmp_path / "left.sqlite3")
|
||||||
|
right = CivicPulseService(tmp_path / "right.sqlite3")
|
||||||
|
|
||||||
|
org = left.create_org("Coalition A")
|
||||||
|
left.create_task("campaign-1", "Call supporters", org.id, fields={"region": "North"})
|
||||||
|
result = left.sync_with(right)
|
||||||
|
|
||||||
|
assert result.accepted > 0
|
||||||
|
assert right.ledger.count() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_deterministic_replay_orders_by_timestamp_and_hash(tmp_path: Path) -> None:
|
||||||
|
service = CivicPulseService(tmp_path / "ledger.sqlite3")
|
||||||
|
org = service.create_org("Organization")
|
||||||
|
service.create_task("campaign", "Door knock", org.id)
|
||||||
|
|
||||||
|
records = service.ledger.all()
|
||||||
|
replayed = deterministic_replay(records)
|
||||||
|
|
||||||
|
assert replayed == sorted(records, key=lambda item: (item.timestamp, item.hash))
|
||||||
Loading…
Reference in New Issue