Spaces:
Running on Zero
Running on Zero
File size: 25,194 Bytes
70650b7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 | # M31 β Civil Defense (NRW BevΓΆlkerungsschutz Pilot)
**Spec version:** v3.0 β *experimental*
**Depends on:** [M03 Capability Bus](../../modules/M03-capability-bus.md), [X02 Event Log](../../cross-cutting/X02-events.md), [M01 Identity](../../modules/M01-identity.md), [M11 Notifications](../../modules/M11-notifications.md), [M14 Federation](../../phase-2/modules/M14-federation.md), [M16 Tokens](../../phase-2/modules/M16-tokens.md), [M30 Evidence](./M30-evidence-ebkh.md), [M29 LoRa Beacons](./M29-lora-beacons.md), [M22 Mobile Native](../../phase-2/modules/M22-mobile-native.md)
**Depended on by:** nothing β terminal module; civil defense is a downstream consumer of everything else
---
## 1. Responsibility
A scoped pilot for **NRW BevΓΆlkerungsschutz**: integrate HearthNet with the role structures that Germany's civil-defence ecosystem actually uses (THW, DRK, Feuerwehr, Katastrophenschutz) so that during an incident, role-certified members can publish authenticated alerts, coordinate locally, and produce a tamper-evident audit trail that survives legal review.
This module is deliberately **regional and regulated**. It does *not* try to be a global civil-defence platform. It encodes the role taxonomy, certificate semantics, and audit-retention rules that apply in Nordrhein-Westfalen, with hooks for other German LΓ€nder and EU regions to plug in later. The pilot lives in Issum and the Niederrhein because that's where Christof can actually walk into a Feuerwehrhaus and get this tested with humans who will use it under stress.
Where the rest of HearthNet aims for "soft consensus across neighbours", this module aims for "hard provenance, signed by an authority, retained per legal mandate". Different ergonomics. Different threat model.
---
## 2. Non-goals
- **Replacing official alert systems.** NINA, KATWARN, Cell-Broadcast, and BOS radio remain the authoritative channels. M31 is *complementary* β it works when official channels are degraded, congested, or geographically miss the affected area, and it carries the *local context* that mass-broadcast systems can't.
- **Issuing legally binding evacuation orders.** Those come from the Krisenstab and are out of any AI-mediated system's authority.
- **Modelling every German Land.** v3.0 targets NRW; M31 has a region adapter so others can be added, but the module ships with NRW only.
- **Replacing TETRA-BOS.** Professional emergency-services radio is its own thing. We coexist; we don't interop.
- **Automatic identity verification of certificate holders.** A role certificate carries who issued it and who it was issued to. *Verifying* that a holder is who they claim is the issuer's responsibility, not ours. We check the signature chain; we don't re-do the background check.
- **Persistent geolocation of helpers.** We record where alerts target and where reported incidents are. We do not continuously track helpers' phones.
---
## 3. File layout
```
hearthnet/civdef/
βββ __init__.py
βββ service.py # CivilDefenseService β capability handler
βββ alert.py # Alert, AlertEnvelope, AlertSeverity dataclasses
βββ role.py # RoleCertificate, role schemas per region
βββ audit.py # Tamper-evident audit chain + export
βββ regions/
β βββ __init__.py
β βββ nrw.py # NRW role taxonomy & issuer trust roots
β βββ _stubs.py # other LΓ€nder placeholders
βββ target.py # Geographic / role / channel targeting
βββ ack.py # Acknowledgement collection
```
---
## 4. Public API
### 4.1 Dataclasses
```python
AlertID = NewType("AlertID", str) # ULID
AlertSeverity = Literal["info","advisory","warning","emergency","extreme"]
@dataclass(frozen=True)
class RoleCertificate:
cert_id: str
holder: NodeID
role: str # canonical role, e.g. "DE.NRW.THW.OV.Leiter"
region: str # "DE.NRW.KreisKleve"
issuer: NodeID # issuing authority's HearthNet identity
issuer_chain: tuple[NodeID, ...] # chain back to a trust root
issued_at: datetime
expires_at: datetime
scopes: frozenset[str] # what this cert is allowed to do
signature: bytes
revocation_url: str | None
@dataclass(frozen=True)
class AlertTarget:
region: str # "DE.NRW.KreisKleve.Issum"
bbox: Bbox | None # optional precise geo target
roles: tuple[str, ...] # which roles should see this; empty = public
channels: tuple[Literal["push","lora","federation","local"], ...]
@dataclass(frozen=True)
class Alert:
alert_id: AlertID
severity: AlertSeverity
title: str # β€ 80 chars
body: str # β€ 1000 chars
target: AlertTarget
instructions: tuple[str, ...] # short imperative lines
published_at: datetime
expires_at: datetime
publisher: NodeID
publisher_role: str
publisher_cert: str # cert_id
evidence_claim: ClaimID | None # link to M30 claim chain if relevant
correlation_id: str | None # links to NINA/KATWARN ID if mirrored
signature: bytes # publisher signs the alert
issuer_attestation: bytes | None # optional co-sign by a higher-tier issuer
@dataclass(frozen=True)
class AlertEnvelope:
alert: Alert
federation_hops: tuple[NodeID, ...] # forward path for audit
received_at: datetime
received_via: Literal["bus","federation","lora_signal","manual"]
@dataclass(frozen=True)
class Ack:
alert_id: AlertID
acker: NodeID
acked_at: datetime
status: Literal["received","acting","need_help","standing_down","mistaken"]
note: str # β€ 280 chars
signature: bytes
@dataclass(frozen=True)
class AuditEntry:
seq: int # monotonic per audit chain
alert_id: AlertID
event: str # "published","forwarded","acked","mirrored","cancelled"
actor: NodeID
at: datetime
payload_sha: str
prev_sha: str # chain-link to previous audit entry
signature: bytes
```
### 4.2 Capabilities
All under `experimental.civdef.*`:
```python
async def civdef_alert_publish(draft: AlertDraft) -> AlertID
async def civdef_alert_cancel(alert_id: AlertID, reason: str) -> CancelReceipt
async def civdef_alert_list(active_only: bool = True,
severity_min: AlertSeverity = "info") -> list[Alert]
async def civdef_alert_get(alert_id: AlertID) -> AlertEnvelope
async def civdef_alert_subscribe(target_filter: AlertTarget | None = None) -> AsyncIterator[AlertEnvelope]
async def civdef_alert_ack(alert_id: AlertID, status: AckStatus, note: str = "") -> AckReceipt
async def civdef_alert_acks(alert_id: AlertID) -> list[Ack]
async def civdef_role_register(cert: RoleCertificate) -> RegisterReceipt
async def civdef_role_list() -> list[RoleCertificate]
async def civdef_role_revoke(cert_id: str, reason: str) -> RevokeReceipt # issuer-only
async def civdef_audit_export(alert_id: AlertID | None = None,
since: datetime | None = None,
format: Literal["jsonl","pdf"] = "jsonl") -> bytes
```
### 4.3 Service class
```python
class CivilDefenseService:
def __init__(self,
bus: CapabilityBus,
event_log: EventLog,
identity: IdentityService,
notifications: NotificationService,
federation: FederationService,
evidence: EvidenceService | None,
region: RegionAdapter,
audit_store: AuditChainStore,
config: CivDefConfig): ...
async def publish_alert(self, draft: AlertDraft, publisher_cert: RoleCertificate) -> AlertID: ...
async def cancel_alert(self, alert_id: AlertID, reason: str, by_cert: RoleCertificate) -> None: ...
async def receive_alert(self, envelope: AlertEnvelope) -> None: ...
async def register_role(self, cert: RoleCertificate) -> None: ...
async def revoke_role(self, cert_id: str, by_cert: RoleCertificate, reason: str) -> None: ...
async def ack(self, alert_id: AlertID, status: AckStatus, note: str) -> AckReceipt: ...
async def export_audit(self, ...) -> bytes: ...
```
### 4.4 Region adapter
```python
class RegionAdapter(Protocol):
region_code: str
trust_roots: tuple[NodeID, ...] # public keys of recognised issuers
role_schema: dict[str, RoleSpec] # role name β spec
audit_retention_years: int
mandatory_severity_minimums: dict[str, AlertSeverity] # role β max severity it can publish
def validate_role(self, cert: RoleCertificate) -> None: ...
def validate_alert(self, draft: AlertDraft, publisher_cert: RoleCertificate) -> None: ...
```
`regions/nrw.py` ships the NRW taxonomy with roles drawn from real-world structure: `DE.NRW.<Kreis>.<Gemeinde>.<Org>.<Role>`, e.g. `DE.NRW.Kleve.Issum.Feuerwehr.Wehrleiter`, `DE.NRW.Kleve.THW.OV.Leiter`, `DE.NRW.Kleve.DRK.Ortsverein.Bereitschaftsleiter`, `DE.NRW.Kleve.KatS.Stabsleiter`. Each role declares maximum severity it may publish, geographic scope it may target, and whether it may co-sign cross-org alerts.
### 4.5 Audit chain store
```python
class AuditChainStore:
"""Append-only, signed, hash-chained audit log.
Retention is governed by config.audit_retention_years; default is 10 (NRW pragmatic baseline,
operator must confirm against current Landesarchivgesetz at deployment time).
"""
async def append(self, entry: AuditEntry) -> None: ...
async def latest(self) -> AuditEntry | None: ...
async def get_range(self, start_seq: int, end_seq: int) -> list[AuditEntry]: ...
async def verify_chain(self, start: int = 0, end: int | None = None) -> VerifyReport: ...
async def export(self, ...) -> bytes: ...
```
---
## 5. Behaviour
### 5.1 Role certification
Role certificates form a chain to a regional trust root. NRW's trust roots are configured at deployment time and should match published issuer keys (Innenministerium NRW, the Kreis Kleve administration, etc. β note that as of v3.0 these *do not* publish HearthNet-compatible keys; the pilot uses a substitute issuance ceremony where the local Wehrleiter signs certificates after manual identity verification, and a clear migration path to real institutional keys is documented).
A certificate may be:
- **Issued** β signed by an authority that itself chains to a trust root.
- **Active** β within validity window and not revoked.
- **Revoked** β explicitly revoked by issuer; revocation is itself signed and appended to the audit chain.
- **Expired** β past `expires_at`.
Service operations that require a role check the certificate at every invocation. Revocations propagate via federation; a node receiving a revocation must, on next receipt of an alert signed by the revoked cert, refuse delivery and emit `civdef.alert.dropped.revoked`.
### 5.2 Alert publication
```
publish_alert(draft, cert):
1. cert.holder must equal self.identity β else civdef_cert_not_owned
2. cert active, not revoked, not expired β else civdef_cert_invalid
3. region.validate_role(cert) β else civdef_cert_unrecognised
4. region.validate_alert(draft, cert) (severity / scope match) β else civdef_cert_out_of_scope
5. Construct Alert with publisher_role from cert.role
6. Sign Alert with self.identity
7. (optional) collect issuer_attestation if config requires co-sign
8. Append to audit chain: event="published"
9. Emit civdef.alert.published event
10. Distribute:
- "local" β notifications via M11 to local subscribers
- "push" β mobile-native delivery via M22
- "federation" β M14 forwarding to federated nodes matching target.region
- "lora" β if M29 enabled, set FLAG_PANIC on the next beacon as a presence-of-alert signal
11. Optionally mirror to evidence graph (M30) as a claim record
12. Return AlertID
```
If the publisher loses connectivity mid-publish, the audit-chain `published` entry has already been appended locally, so the alert is recoverable on reconnect and re-distributes from there. Idempotent on AlertID.
### 5.3 Targeting
`AlertTarget` is a set of orthogonal filters:
- **region** β hierarchical region code; matches by prefix (`DE.NRW.Kleve` matches `DE.NRW.Kleve.Issum`).
- **bbox** β optional geographic bounding box (overrides region for the precise area).
- **roles** β empty means public; non-empty restricts visibility to certificate holders of those roles.
- **channels** β which delivery mechanisms to use.
A receiving node filters on its own identity's location, registered roles, and active subscriptions. The filter is enforced **client-side at delivery** as well as **publisher-side at distribution**, so a node that mis-claims a role doesn't expose role-only content (the federation forwarder uses publisher-side filtering when forwarding `roles`-restricted alerts).
### 5.4 Acknowledgements
When a role-targeted alert arrives, the recipient may ack with a status:
- `received` β read confirmation.
- `acting` β operationally taking action (e.g., Feuerwehr en route).
- `need_help` β recipient cannot act; help requested.
- `standing_down` β alert handled, recipient disengages.
- `mistaken` β the recipient believes this alert is in error; an attached `note` should explain.
Acks are signed, appended to the audit chain, and visible to the publisher via `civdef.alert.acks(alert_id)`. Public alerts (no `roles` filter) suppress acks unless `config.allow_public_ack=true` β to prevent ack floods on widely-distributed alerts.
### 5.5 Cancellation
Cancellation requires a certificate with cancel scope (typically the original publisher or a same-or-higher role in the same region). A cancellation:
1. Records the cancellation in the audit chain.
2. Emits `civdef.alert.cancelled` to all original delivery channels.
3. Marks the alert inactive in `civdef_alert_list` queries (`active_only=true`).
The original alert is not deleted. Audit retention applies to the cancellation as well.
### 5.6 Audit chain
The audit chain is an append-only, hash-chained, signed log specific to this module. Each entry's `prev_sha` is the SHA-256 of the previous entry's canonicalised body, creating a tamper-evident chain. `verify_chain` walks from genesis (or a checkpoint) verifying signatures and hashes; failure raises `civdef_audit_chain_broken` and is surfaced as a high-priority operator notification.
Audit entries cover: alert published, alert forwarded (with federation hop), alert acked, alert cancelled, role certificate registered, role certificate revoked, audit chain checkpointed. Export produces `jsonl` (machine-readable, default) or `pdf` (operator-readable for legal review, generated via the public `pdf` skill).
Retention is governed by `CIVDEF_AUDIT_RETENTION_YEARS` (default 10 β operator must validate against current NRW Landesarchivgesetz at deployment; the constant is the recommendation, not the law).
### 5.7 Federation interaction
Alerts cross federation boundaries via M14. The federation manifest must declare `civdef` as an advertised capability; otherwise the alert is not forwarded into the neighbouring community. Forwarding nodes append themselves to `AlertEnvelope.federation_hops` for audit, but do not re-sign the alert (the publisher's signature is the source of truth). The receiving community independently audits the alert against its own role schemas; if the publisher's role is not recognised, the alert is delivered with a `civdef.alert.foreign_role` flag and is *not* surfaced as a high-severity push.
### 5.8 LoRa interaction
LoRa beacons (M29) carry no alert content; they carry only presence. When the local node receives a `severity β {emergency, extreme}` alert and LoRa is enabled, the node sets `FLAG_PANIC` on its next beacon and increases beacon cadence to the panic-burst configured in M29. This is a *signal* that something is happening, not a *content* channel. Receivers must consult bus or notifications for the actual alert content.
### 5.9 Failure modes
- **Publisher's cert revoked after publish, before propagation completes**: federation forwarders that have received the revocation drop the in-flight alert; nodes that have not yet seen the revocation propagate normally. Eventually consistent; documented limitation.
- **Audit chain corruption** (disk failure, manual tampering): `verify_chain` detects; the module enters degraded mode where new publishes are blocked until an operator acknowledges and re-checkpoints. Reads continue.
- **Trust root key compromise**: out of scope for v3.0 to *recover* automatically; documented incident response: revoke all certs chaining to the compromised root, rotate root, reissue.
- **Mass-ack flood**: `allow_public_ack=false` default; per-alert ack rate-limit `CIVDEF_ACK_MAX_PER_MINUTE_PER_NODE`.
---
## 6. Errors
| Code | When |
|-----------------------------------|-------------------------------------------------------------------|
| `experimental_disabled` | Capability called with the flag off |
| `civdef_cert_not_owned` | Publish/ack with a cert whose holder β caller's identity |
| `civdef_cert_invalid` | Certificate expired, revoked, or signature broken |
| `civdef_cert_unrecognised` | Issuer chain doesn't terminate at a configured trust root |
| `civdef_cert_out_of_scope` | Cert's role/region doesn't authorise the requested action |
| `civdef_alert_not_found` | Operation references an unknown AlertID |
| `civdef_alert_target_invalid` | Target region/bbox malformed or outside the issuer's scope |
| `civdef_audit_chain_broken` | Hash or signature mismatch in the audit chain |
| `civdef_role_revoked` | Operation attempted with a revoked certificate |
| `civdef_region_unsupported` | No region adapter loaded for the requested region |
| `civdef_ack_rate_limited` | Ack rate exceeded for this alert from this node |
---
## 7. Configuration
```python
@dataclass(frozen=True)
class CivDefConfig:
enabled: bool = False
region: str = "DE.NRW"
audit_retention_years: int = CIVDEF_AUDIT_RETENTION_YEARS # 10
require_issuer_cosign: dict[AlertSeverity, bool] = field(default_factory=lambda: {
"info": False, "advisory": False, "warning": False,
"emergency": True, "extreme": True,
})
allow_public_ack: bool = False
ack_max_per_minute_per_node: int = CIVDEF_ACK_MAX_PER_MINUTE_PER_NODE # 5
federation_forward: bool = True
lora_panic_signal: bool = True
severity_push_threshold: AlertSeverity = "warning" # below this, no mobile push
trust_roots_extra: tuple[NodeID, ...] = () # operator-added roots
region_adapter_overrides: dict[str, str] = field(default_factory=dict)
```
Constants centralised in `hearthnet/constants.py`.
---
## 8. Tests
### 8.1 Unit
- `test_role_cert_chain_to_root` β cert with valid chain β accepted; broken chain β rejected.
- `test_role_cert_expired` β past `expires_at` β `civdef_cert_invalid`.
- `test_alert_signature_roundtrip`.
- `test_target_region_prefix_match` β `DE.NRW.Kleve` matches `DE.NRW.Kleve.Issum`, not `DE.NRW.Wesel`.
- `test_audit_chain_link` β appending entries chains correctly; `verify_chain` returns ok.
- `test_audit_chain_tamper_detected` β flip a byte in the middle; `verify_chain` reports the break.
- `test_severity_cap_per_role` β Wehrleiter publishing `extreme` β `civdef_cert_out_of_scope` if schema caps at `emergency`.
- `test_revocation_propagates` β revoke cert; subsequent alerts from that cert dropped.
### 8.2 Integration
- Two-node alert flow: node A (Wehrleiter cert) publishes `warning` alert targeting `DE.NRW.Kleve.Issum`; node B (resident in Issum, no cert) receives via M11 push.
- Role-targeted alert: A publishes alert with `roles=("DE.NRW.Kleve.THW.OV.Leiter",)`; B (without cert) does not receive; C (with cert) does.
- Federation: A publishes in community X; X federates to Y; Y's resident D receives with `federation_hops=[X]`.
- Cancellation: A cancels; B's alert list moves it to inactive.
- Audit export: publish, ack, cancel; export `jsonl`; round-trip parses and `verify_chain` passes.
### 8.3 Negative / adversarial
- Forged cert chain (random issuer key) β `civdef_cert_unrecognised`.
- Targeting `DE.BY` (outside NRW) from an NRW-only cert β `civdef_alert_target_invalid`.
- Ack flood beyond rate limit β `civdef_ack_rate_limited`.
- Tampered audit chain β publish blocked until operator re-checkpoint.
### 8.4 Tabletop
- Manual scenarios with Issum Feuerwehr volunteers: simulated Hochwasser event, simulated grid outage, simulated industrial incident on the A57. Goals: latency from alert publication to first ack, false-positive ack rate, operator-perceived clarity of UI under stress.
---
## 9. Cross-references
- **Phase 1 M01 Identity** β every cert, alert, ack, and audit entry is signed against M01 identities.
- **Phase 1 M11 Notifications** β alerts surface via notifications with priority mapped from `severity`.
- **Phase 2 M14 Federation** β alerts cross community boundaries via federation.
- **Phase 2 M16 Tokens** β cert validation reuses M16's signature primitives; alert distribution endpoints require `civdef-receive` scoped tokens.
- **Phase 2 M22 Mobile Native** β mobile push for `severity β₯ severity_push_threshold`.
- **Phase 3 M29 LoRa Beacons** β `FLAG_PANIC` corroboration during emergencies.
- **Phase 3 M30 Evidence** β alerts may carry an `evidence_claim` ClaimID; recipients can `evidence.provenance.trace` to see the reasoning chain.
- **Phase 3 X09 Conformance Suite** β civdef has a dedicated conformance section because of audit-chain integrity requirements.
---
## 10. Open research questions
1. **Real institutional keys.** v3.0 uses substitute issuance because NRW authorities do not (yet) publish HearthNet-compatible keys. The migration path β getting the Innenministerium or Kreis Kleve to publish keys and sign initial role certs β is a political process, not a technical one. Documented; out of code scope.
2. **NINA / KATWARN bridge.** A read-only mirror that pulls public NINA alerts and republishes them locally with a `correlation_id` is plausible and would be valuable. Whether it's M31's job or a separate bridge module is undecided.
3. **Multi-Land schema.** The NRW role taxonomy is concrete; Bayern, Niedersachsen, Hessen each have variations (especially around KatS structures). A community-contributed `regions/` directory is the plan; v3.0 ships only NRW.
4. **Co-signing UX.** When `require_issuer_cosign=true` for emergencies, the publisher must obtain a co-signature from a higher-tier issuer. Latency-sensitive. A pre-delegated "emergency co-sign authority" mechanism (similar to OCSP-stapling for certs) is the obvious extension. Not in v3.0.
5. **Public-ack ergonomics.** Public alerts with `allow_public_ack=true` would let citizens self-report ("I am safe", "I need help"), but the failure modes (ack flood, false reports) are severe enough that v3.0 defaults this off. A future tier with rate limits and ack-content moderation is plausible.
6. **Legal retention.** `CIVDEF_AUDIT_RETENTION_YEARS=10` is the operator-friendly default. Actual legal retention varies (NRW Landesarchivgesetz, federal data retention rules for civil-defence records, GDPR exceptions for vital interests). The deployment guide must explicitly walk operators through this; we cannot guess from code.
7. **Cross-border alerts.** Issum borders the Netherlands. An alert about a Dutch industrial incident might originate from a Dutch system. Cross-border interop is interesting and outside v3.0 scope. The `region` adapter pattern doesn't preclude it.
8. **Drills and false-alarm semantics.** A drill should look real enough to be useful and clearly different enough to not panic non-participants. A `drill=true` flag on Alert is the obvious addition; v3.0 omits it pending feedback from real drill rehearsals.
---
*Last updated: spec v3.0.*
|