aetherruntime-arch 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ """
2
+ AetherRuntime v2.0.0 — Recovery: Replay (Layer 5)
3
+
4
+ This module implements Operation 7 (`recover`) from
5
+ docs/specification/02_operations.md.
6
+
7
+ The `recover` operation restores a halted agent to a known-good state:
8
+ 1. Snapshot reconciliation: load the last known-good snapshot.
9
+ 2. Safe replay: replay only actions before the anomaly.
10
+ 3. Verification: verify the recovered state against specifications.
11
+ 4. Reauthorization: the agent must be re-authorized by AetherGovern.
12
+ 5. Resume: the agent resumes at the state before the anomaly.
13
+
14
+ Invariants enforced:
15
+ - I21: verification_result = True before running
16
+ - I22: snapshot_id refers to existing Snapshot
17
+ - I23: reauthorized_by is authorized (via AetherGovern)
18
+ - I33: RecoveryRecord follows a HaltOrder
19
+ - O7: recover requires verification_result = True
20
+ - X1: every admitted action has traceable authority
21
+ - X4: every recovery produces RecoveryRecord with verification_result = True
22
+
23
+ Errors:
24
+ - E19: AgentNotHalted
25
+ - E20: SnapshotNotFound
26
+ - E21: UnauthorizedReauthorizer
27
+ - E22: VerificationFailed
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Optional
33
+
34
+ from ..models import (
35
+ Agent,
36
+ AgentStatus,
37
+ HaltOrder,
38
+ RecoveryRecord,
39
+ Snapshot,
40
+ _now_iso,
41
+ _random_hex,
42
+ )
43
+
44
+
45
+ # ═══════════════════════════════════════════════════════════════════════════
46
+ # Errors
47
+ # ═══════════════════════════════════════════════════════════════════════════
48
+
49
+
50
+ class AgentNotHalted(Exception):
51
+ """E19: Agent status != halted."""
52
+ pass
53
+
54
+
55
+ class SnapshotNotFound(Exception):
56
+ """E20: snapshot_id does not exist."""
57
+ pass
58
+
59
+
60
+ class UnauthorizedReauthorizer(Exception):
61
+ """E21: reauthorized_by is not authorized."""
62
+ pass
63
+
64
+
65
+ class VerificationFailed(Exception):
66
+ """E22: Recovered state does not match specification."""
67
+ pass
68
+
69
+
70
+ # ═══════════════════════════════════════════════════════════════════════════
71
+ # Verification (stub; AetherProof in production)
72
+ # ═══════════════════════════════════════════════════════════════════════════
73
+
74
+
75
+ def _verify_recovered_state(
76
+ agent: Agent,
77
+ snapshot: Snapshot,
78
+ replay_log: list,
79
+ ) -> bool:
80
+ """
81
+ Verify that the recovered state matches specifications.
82
+
83
+ In simulation, we check:
84
+ - Snapshot hash is valid.
85
+ - Replay log contains only safe actions (no anomaly triggers).
86
+
87
+ In production, this would call AetherProof.
88
+ """
89
+ import hashlib
90
+ expected = hashlib.sha256(snapshot.state).hexdigest()
91
+ if expected != snapshot.hash:
92
+ return False
93
+ for action in replay_log:
94
+ if action.get("safe", True) is False:
95
+ return False
96
+ return True
97
+
98
+
99
+ # ═══════════════════════════════════════════════════════════════════════════
100
+ # Operation 7 — recover
101
+ # ═══════════════════════════════════════════════════════════════════════════
102
+
103
+
104
+ def recover(
105
+ agent: Agent,
106
+ halt_order: HaltOrder,
107
+ snapshot: Snapshot,
108
+ replay_log: list,
109
+ reauthorizer: Agent,
110
+ is_reauthorizer_authorized: bool = True,
111
+ ) -> RecoveryRecord:
112
+ """
113
+ Recover a halted agent to a known-good state (Operation 7).
114
+
115
+ Args:
116
+ agent: The agent to recover. Must be in `halted` state.
117
+ halt_order: The HaltOrder that was enforced.
118
+ snapshot: The Snapshot to restore.
119
+ replay_log: List of safe actions to replay.
120
+ reauthorizer: The agent re-authorizing recovery (via AetherGovern).
121
+ is_reauthorizer_authorized: In production, checked via AetherGovern.
122
+
123
+ Returns:
124
+ A new RecoveryRecord with verification_result = True.
125
+
126
+ Raises:
127
+ AgentNotHalted: If agent.status != halted.
128
+ SnapshotNotFound: If snapshot is None.
129
+ UnauthorizedReauthorizer: If reauthorizer is not authorized.
130
+ VerificationFailed: If recovered state does not match spec.
131
+
132
+ Invariants:
133
+ I21: verification_result = True before running.
134
+ I22: snapshot_id refers to existing Snapshot.
135
+ I23: reauthorized_by is authorized.
136
+ I33: RecoveryRecord follows a HaltOrder.
137
+ O7: recover requires verification_result = True.
138
+ X4: every recovery produces RecoveryRecord with verification_result = True.
139
+
140
+ Example:
141
+ >>> record = recover(agent, halt, snapshot, [], reauth)
142
+ >>> record.verification_result
143
+ True
144
+ >>> agent.status
145
+ <AgentStatus.RUNNING: 'running'>
146
+ """
147
+ # ─── E19: Agent must be halted ───
148
+ if agent.status != AgentStatus.HALTED:
149
+ raise AgentNotHalted(
150
+ f"Agent {agent.agent_id} is not halted (status: {agent.status.value})"
151
+ )
152
+
153
+ # ─── E20: Snapshot must exist ───
154
+ if snapshot is None:
155
+ raise SnapshotNotFound("snapshot must not be None")
156
+ if snapshot.agent_id != agent.agent_id:
157
+ raise SnapshotNotFound(
158
+ f"snapshot {snapshot.snapshot_id} does not belong to "
159
+ f"agent {agent.agent_id}"
160
+ )
161
+
162
+ # ─── I33: RecoveryRecord must follow a HaltOrder ───
163
+ if halt_order is None or halt_order.agent_id != agent.agent_id:
164
+ raise ValueError(
165
+ f"halt_order does not match agent {agent.agent_id}"
166
+ )
167
+
168
+ # ─── E21: Reauthorizer must be authorized ───
169
+ if not is_reauthorizer_authorized:
170
+ raise UnauthorizedReauthorizer(
171
+ f"Reauthorizer {reauthorizer.agent_id} is not authorized"
172
+ )
173
+
174
+ # ─── E22: Verify recovered state ───
175
+ verification_result = _verify_recovered_state(agent, snapshot, replay_log)
176
+ if not verification_result:
177
+ raise VerificationFailed(
178
+ f"Verification failed for agent {agent.agent_id}"
179
+ )
180
+
181
+ # ─── Q4: Update agent status to `recovering` ───
182
+ agent.status = AgentStatus.RECOVERING
183
+
184
+ # ─── Q1: Create RecoveryRecord ───
185
+ record = RecoveryRecord(
186
+ recovery_id=_random_hex(),
187
+ agent_id=agent.agent_id,
188
+ halt_id=halt_order.halt_id,
189
+ snapshot_id=snapshot.snapshot_id,
190
+ replay_log=replay_log,
191
+ verification_result=verification_result,
192
+ timestamp=_now_iso(),
193
+ reauthorized_by=reauthorizer.agent_id,
194
+ )
195
+
196
+ # ─── Q4: Update agent status to `running` ───
197
+ agent.status = AgentStatus.RUNNING
198
+
199
+ return record
200
+
201
+
202
+ # ═══════════════════════════════════════════════════════════════════════════
203
+ # Recovery registry
204
+ # ═══════════════════════════════════════════════════════════════════════════
205
+
206
+
207
+ class RecoveryRegistry:
208
+ """An in-memory registry of recovery records."""
209
+
210
+ def __init__(self) -> None:
211
+ self._records: dict[str, RecoveryRecord] = {}
212
+ self._by_agent: dict[str, list[RecoveryRecord]] = {}
213
+
214
+ def put(self, record: RecoveryRecord) -> None:
215
+ """Register a recovery record."""
216
+ if record.recovery_id in self._records:
217
+ raise ValueError(
218
+ f"recovery_id {record.recovery_id} already registered"
219
+ )
220
+ self._records[record.recovery_id] = record
221
+ self._by_agent.setdefault(record.agent_id, []).append(record)
222
+
223
+ def get(self, recovery_id: str) -> Optional[RecoveryRecord]:
224
+ """Get a recovery record by ID."""
225
+ return self._records.get(recovery_id)
226
+
227
+ def get_by_agent(self, agent_id: str) -> list[RecoveryRecord]:
228
+ """Get all recovery records for an agent."""
229
+ return list(self._by_agent.get(agent_id, []))
230
+
231
+ def count(self) -> int:
232
+ """Count all recovery records."""
233
+ return len(self._records)
234
+
235
+ def clear(self) -> None:
236
+ """Clear all records."""
237
+ self._records.clear()
238
+ self._by_agent.clear()
239
+
240
+
241
+ # ═══════════════════════════════════════════════════════════════════════════
242
+ # Recovery statistics
243
+ # ═══════════════════════════════════════════════════════════════════════════
244
+
245
+
246
+ class RecoveryStats:
247
+ """Aggregated statistics for recovery records."""
248
+
249
+ def __init__(self, records: list[RecoveryRecord]) -> None:
250
+ self.records = records
251
+
252
+ @property
253
+ def total(self) -> int:
254
+ return len(self.records)
255
+
256
+ @property
257
+ def n_verified(self) -> int:
258
+ return sum(1 for r in self.records if r.verification_result)
259
+
260
+ @property
261
+ def verification_rate(self) -> float:
262
+ if self.total == 0:
263
+ return 0.0
264
+ return self.n_verified / self.total
265
+
266
+ def __repr__(self) -> str:
267
+ return (
268
+ f"RecoveryStats(total={self.total}, "
269
+ f"verified={self.n_verified}, "
270
+ f"rate={self.verification_rate:.3f})"
271
+ )
@@ -0,0 +1,199 @@
1
+ """
2
+ AetherRuntime v2.0.0 — Recovery: Snapshot (Layer 5)
3
+
4
+ This module provides snapshot management for recovery.
5
+ A snapshot is a point-in-time capture of an agent's state.
6
+
7
+ Key properties:
8
+ - Content-addressed (SHA-256 hash).
9
+ - Stored in AetherGit (simulated).
10
+ - Used by `recover` (see `replay.py`).
11
+
12
+ Invariants enforced:
13
+ - I24: hash matches SHA-256 of state
14
+ - I25: stored_at refers to an existing AetherGit record
15
+ - I26: snapshot_id is unique
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ from datetime import datetime, timezone
22
+ from typing import Optional
23
+
24
+ from ..models import (
25
+ Agent,
26
+ Snapshot,
27
+ _now_iso,
28
+ _random_hex,
29
+ )
30
+
31
+
32
+ # ═══════════════════════════════════════════════════════════════════════════
33
+ # Errors
34
+ # ═══════════════════════════════════════════════════════════════════════════
35
+
36
+
37
+ class SnapshotNotFound(Exception):
38
+ """Snapshot does not exist."""
39
+ pass
40
+
41
+
42
+ class HashMismatch(Exception):
43
+ """I24: hash does not match SHA-256 of state."""
44
+ pass
45
+
46
+
47
+ # ═══════════════════════════════════════════════════════════════════════════
48
+ # Snapshot creation
49
+ # ═══════════════════════════════════════════════════════════════════════════
50
+
51
+
52
+ def create_snapshot(
53
+ agent: Agent,
54
+ state: bytes,
55
+ stored_at: Optional[str] = None,
56
+ ) -> Snapshot:
57
+ """
58
+ Create a snapshot of an agent's state.
59
+
60
+ Args:
61
+ agent: The agent to snapshot.
62
+ state: Serialized agent state (bytes).
63
+ stored_at: Storage location (AetherGit ref). Defaults to a generated ref.
64
+
65
+ Returns:
66
+ A new Snapshot with a valid SHA-256 hash.
67
+
68
+ Raises:
69
+ ValueError: If state is empty or agent is None.
70
+
71
+ Invariants:
72
+ I24: hash matches SHA-256 of state.
73
+ I25: stored_at refers to a valid location.
74
+ I26: snapshot_id is unique.
75
+
76
+ Example:
77
+ >>> snapshot = create_snapshot(agent, b"state data")
78
+ >>> len(snapshot.hash)
79
+ 64
80
+ """
81
+ if agent is None:
82
+ raise ValueError("agent must not be None")
83
+ if not state:
84
+ raise ValueError("state must not be empty")
85
+
86
+ # ─── I24: Compute SHA-256 hash ───
87
+ state_hash = hashlib.sha256(state).hexdigest()
88
+
89
+ # ─── I25: Default storage location ───
90
+ if stored_at is None:
91
+ stored_at = f"aethergit://snapshots/{state_hash[:16]}"
92
+
93
+ # ─── I26: Generate unique snapshot_id ───
94
+ snapshot_id = _random_hex()
95
+
96
+ return Snapshot(
97
+ snapshot_id=snapshot_id,
98
+ agent_id=agent.agent_id,
99
+ state=state,
100
+ hash=state_hash,
101
+ timestamp=_now_iso(),
102
+ stored_at=stored_at,
103
+ )
104
+
105
+
106
+ def verify_snapshot(snapshot: Snapshot) -> bool:
107
+ """
108
+ Verify that a snapshot's hash matches its state (I24).
109
+
110
+ Args:
111
+ snapshot: The Snapshot to verify.
112
+
113
+ Returns:
114
+ True if hash matches, False otherwise.
115
+ """
116
+ expected = hashlib.sha256(snapshot.state).hexdigest()
117
+ return expected == snapshot.hash
118
+
119
+
120
+ # ═══════════════════════════════════════════════════════════════════════════
121
+ # Snapshot registry
122
+ # ═══════════════════════════════════════════════════════════════════════════
123
+
124
+
125
+ class SnapshotRegistry:
126
+ """
127
+ An in-memory registry of snapshots.
128
+
129
+ Indexes:
130
+ - by_id: dict[snapshot_id → Snapshot]
131
+ - by_agent: dict[agent_id → list[Snapshot]]
132
+ """
133
+
134
+ def __init__(self) -> None:
135
+ self._by_id: dict[str, Snapshot] = {}
136
+ self._by_agent: dict[str, list[Snapshot]] = {}
137
+
138
+ def put(self, snapshot: Snapshot) -> None:
139
+ """Register a snapshot (I26: snapshot_id unique)."""
140
+ if snapshot.snapshot_id in self._by_id:
141
+ raise ValueError(
142
+ f"snapshot_id {snapshot.snapshot_id} already registered"
143
+ )
144
+ # I24: verify hash before storing
145
+ if not verify_snapshot(snapshot):
146
+ raise HashMismatch(
147
+ f"snapshot {snapshot.snapshot_id} has invalid hash"
148
+ )
149
+ self._by_id[snapshot.snapshot_id] = snapshot
150
+ self._by_agent.setdefault(snapshot.agent_id, []).append(snapshot)
151
+
152
+ def get(self, snapshot_id: str) -> Optional[Snapshot]:
153
+ """Get a snapshot by ID."""
154
+ return self._by_id.get(snapshot_id)
155
+
156
+ def get_by_agent(self, agent_id: str) -> list[Snapshot]:
157
+ """Get all snapshots for an agent."""
158
+ return list(self._by_agent.get(agent_id, []))
159
+
160
+ def latest_for_agent(self, agent_id: str) -> Optional[Snapshot]:
161
+ """Get the latest snapshot for an agent."""
162
+ snapshots = self.get_by_agent(agent_id)
163
+ if not snapshots:
164
+ return None
165
+ snapshots.sort(
166
+ key=lambda s: datetime.fromisoformat(
167
+ s.timestamp.replace("Z", "+00:00")
168
+ ),
169
+ reverse=True,
170
+ )
171
+ return snapshots[0]
172
+
173
+ def count(self) -> int:
174
+ """Count all snapshots."""
175
+ return len(self._by_id)
176
+
177
+ def count_by_agent(self, agent_id: str) -> int:
178
+ """Count snapshots for an agent."""
179
+ return len(self._by_agent.get(agent_id, []))
180
+
181
+ def clear(self) -> None:
182
+ """Clear all snapshots."""
183
+ self._by_id.clear()
184
+ self._by_agent.clear()
185
+
186
+
187
+ # ═══════════════════════════════════════════════════════════════════════════
188
+ # Convenience functions
189
+ # ═══════════════════════════════════════════════════════════════════════════
190
+
191
+
192
+ def get_snapshot_hash(state: bytes) -> str:
193
+ """Compute the SHA-256 hash of a state."""
194
+ return hashlib.sha256(state).hexdigest()
195
+
196
+
197
+ def compare_snapshots(a: Snapshot, b: Snapshot) -> bool:
198
+ """Check if two snapshots have the same state."""
199
+ return a.hash == b.hash
@@ -0,0 +1,37 @@
1
+ """AetherRuntime — Simulation layer (Layer 6, simulation only)."""
2
+
3
+ from .network import (
4
+ Network,
5
+ NetworkState,
6
+ PartitionSchedule,
7
+ Message,
8
+ )
9
+ from .node import (
10
+ Node,
11
+ create_cluster,
12
+ )
13
+ from .testbed import (
14
+ Testbed,
15
+ TestbedConfig,
16
+ Scenario,
17
+ SCENARIOS,
18
+ experiment_1_detection_accuracy,
19
+ experiment_2_halt_window,
20
+ experiment_3_recovery_correctness,
21
+ )
22
+
23
+ __all__ = [
24
+ "Network",
25
+ "NetworkState",
26
+ "PartitionSchedule",
27
+ "Message",
28
+ "Node",
29
+ "create_cluster",
30
+ "Testbed",
31
+ "TestbedConfig",
32
+ "Scenario",
33
+ "SCENARIOS",
34
+ "experiment_1_detection_accuracy",
35
+ "experiment_2_halt_window",
36
+ "experiment_3_recovery_correctness",
37
+ ]