RFTSystems commited on
Commit
adb9c3b
·
verified ·
1 Parent(s): 77a44e5

Update rft_flightrecorder.py

Browse files
Files changed (1) hide show
  1. rft_flightrecorder.py +694 -120
rft_flightrecorder.py CHANGED
@@ -1,144 +1,718 @@
1
- import os
2
  import json
3
- import threading
4
- import tempfile
 
5
  import zipfile
 
 
6
 
7
- import rft_flightrecorder as fr
8
-
9
-
10
- def two_tab_spam_test(log_path: str, threads: int = 6, events_per_thread: int = 80):
11
- sk_hex, pk_hex = fr.gen_keys()
12
- sid, msg = fr.start_session(log_path, "brutal-test", "deterministic", "spam test", False, sk_hex)
13
- assert sid, f"Failed to start session: {msg}"
14
-
15
- errors = []
16
-
17
- def worker(tid: int):
18
- for i in range(events_per_thread):
19
- payload = json.dumps({"thread": tid, "i": i}, ensure_ascii=False)
20
- ev, m = fr.append_event(
21
- log_path=log_path,
22
- session_id=sid,
23
- event_type="note",
24
- payload_text=payload,
25
- parent_event_hash="",
26
- sign_event=False,
27
- sk_hex=sk_hex,
28
- model_id="brutal-test",
29
- run_mode="deterministic",
30
- )
31
- if not ev:
32
- errors.append(m)
33
-
34
- ts = [threading.Thread(target=worker, args=(t,)) for t in range(threads)]
35
- for t in ts:
36
- t.start()
37
- for t in ts:
38
- t.join()
39
-
40
- assert not errors, f"Append errors occurred (first 5): {errors[:5]}"
41
-
42
- status, ok, report = fr.verify_session(log_path, sid, pk_hex="", require_signatures=False)
43
- assert ok, f"Session failed verification after spam.\n{status}\n{report}"
44
-
45
- anchor, m = fr.finalise_session(log_path, sid, False, sk_hex, "brutal-test", "deterministic")
46
- assert anchor, f"Finalise failed: {m}"
47
-
48
- # Must refuse writes after end
49
- ev, m = fr.append_event(
50
- log_path=log_path,
51
- session_id=sid,
52
- event_type="note",
53
- payload_text='{"post_end": true}',
54
- parent_event_hash="",
55
- sign_event=False,
56
- sk_hex=sk_hex,
57
- model_id="brutal-test",
58
- run_mode="deterministic",
59
- )
60
- assert ev is None and "ended" in m.lower(), f"Expected append to be refused after session_end, got: {m}"
61
-
62
- status, ok, report = fr.verify_session(log_path, sid, pk_hex="", require_signatures=False)
63
- assert ok, f"Session failed verification after finalise.\n{status}\n{report}"
64
 
65
- return sid
66
 
 
 
 
67
 
68
- def tamper_zip_test(log_path: str):
69
- sk_hex, _pk_hex = fr.gen_keys()
70
- sid, msg = fr.start_session(log_path, "brutal-test", "deterministic", "tamper test", False, sk_hex)
71
- assert sid, f"Failed to start session: {msg}"
72
 
73
- for i in range(25):
74
- fr.append_event(
75
- log_path=log_path,
76
- session_id=sid,
77
- event_type="note",
78
- payload_text=json.dumps({"i": i}),
79
- parent_event_hash="",
80
- sign_event=False,
81
- sk_hex=sk_hex,
82
- model_id="brutal-test",
83
- run_mode="deterministic",
84
- )
85
 
86
- fr.finalise_session(log_path, sid, False, sk_hex, "brutal-test", "deterministic")
 
 
87
 
88
- zip_name, msg = fr.export_session_bundle(log_path, sid)
89
- assert zip_name and os.path.exists(zip_name), f"Export failed: {msg}"
 
 
 
 
 
 
90
 
91
- with tempfile.TemporaryDirectory() as td:
92
- with zipfile.ZipFile(zip_name, "r") as z:
93
- z.extractall(td)
94
 
95
- events_file = None
96
- for fn in os.listdir(td):
97
- if fn.endswith("_events.jsonl"):
98
- events_file = os.path.join(td, fn)
99
- break
100
- assert events_file, "No events jsonl found inside exported zip"
101
 
102
- lines = open(events_file, "r", encoding="utf-8").read().splitlines()
103
- assert len(lines) > 5, "Not enough events in bundle to tamper"
104
 
105
- # Tamper: modify payload of an early event but keep stored hash/signature -> MUST FAIL verification
106
- obj = json.loads(lines[4])
107
- obj.setdefault("payload", {})
108
- if isinstance(obj["payload"], dict):
109
- obj["payload"]["tampered"] = True
110
- lines[4] = json.dumps(obj, ensure_ascii=False)
111
 
112
- open(events_file, "w", encoding="utf-8").write("\n".join(lines) + "\n")
113
 
114
- tampered_zip = os.path.join(td, "tampered.zip")
115
- with zipfile.ZipFile(tampered_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
116
- z.write(events_file, arcname=os.path.basename(events_file))
117
 
118
- status, ok, report, _ = fr.import_bundle_verify(
119
- bundle_path=tampered_zip,
120
- pk_hex="",
121
- require_signatures=False,
122
- store_into_log=False,
123
- log_path=log_path,
124
- )
125
- assert not ok, f"Tampered bundle incorrectly verified PASS.\n{status}\n{report}"
126
 
127
- return True
 
128
 
129
 
130
- def main():
131
- with tempfile.TemporaryDirectory() as td:
132
- log_path = os.path.join(td, "flightlog.jsonl")
133
 
134
- sid1 = two_tab_spam_test(log_path, threads=8, events_per_thread=60)
135
- print(f"[PASS] two_tab_spam_test: session_id={sid1}")
 
136
 
137
- ok = tamper_zip_test(log_path)
138
- print(f"[PASS] tamper_zip_test: {ok}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- print("[ALL PASS]")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- if __name__ == "__main__":
144
- main()
 
 
 
 
 
 
 
 
 
 
 
 
1
  import json
2
+ import os
3
+ import uuid
4
+ import hashlib
5
  import zipfile
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional, Tuple
8
 
9
+ from filelock import FileLock, Timeout
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
11
+ Ed25519PrivateKey,
12
+ Ed25519PublicKey,
13
+ )
14
+ from cryptography.hazmat.primitives import serialization
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
 
16
 
17
+ EVENT_SPEC = "rft-flight-event-v0"
18
+ ROOT_SPEC = "rft-flight-session-root-v0"
19
+ DEFAULT_LOG_PATH = "flightlog.jsonl"
20
 
21
+ # Lock timeouts (seconds)
22
+ READ_LOCK_TIMEOUT = 5.0
23
+ WRITE_LOCK_TIMEOUT = 5.0
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ # ============================================================
27
+ # Canonical JSON + hashing
28
+ # ============================================================
29
 
30
+ def canon(obj) -> bytes:
31
+ """Canonical JSON encoding (stable for hashing/verifying across machines)."""
32
+ return json.dumps(
33
+ obj,
34
+ ensure_ascii=False,
35
+ sort_keys=True,
36
+ separators=(",", ":"),
37
+ ).encode("utf-8")
38
 
 
 
 
39
 
40
+ def sha256_hex(b: bytes) -> str:
41
+ return hashlib.sha256(b).hexdigest()
 
 
 
 
42
 
 
 
43
 
44
+ def now_utc_iso() -> str:
45
+ return datetime.now(timezone.utc).isoformat()
 
 
 
 
46
 
 
47
 
48
+ def short(h: str, n: int = 12) -> str:
49
+ h = (h or "").strip()
50
+ return h[:n] if len(h) > n else h
51
 
 
 
 
 
 
 
 
 
52
 
53
+ def _lock_path(log_path: str) -> str:
54
+ return f"{log_path}.lock"
55
 
56
 
57
+ # ============================================================
58
+ # Key management (Ed25519)
59
+ # ============================================================
60
 
61
+ def gen_keys() -> Tuple[str, str]:
62
+ sk = Ed25519PrivateKey.generate()
63
+ pk = sk.public_key()
64
 
65
+ sk_b = sk.private_bytes(
66
+ encoding=serialization.Encoding.Raw,
67
+ format=serialization.PrivateFormat.Raw,
68
+ encryption_algorithm=serialization.NoEncryption(),
69
+ )
70
+ pk_b = pk.public_bytes(
71
+ encoding=serialization.Encoding.Raw,
72
+ format=serialization.PublicFormat.Raw,
73
+ )
74
+ return sk_b.hex(), pk_b.hex()
75
+
76
+
77
+ def load_sk(sk_hex: str) -> Ed25519PrivateKey:
78
+ return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(sk_hex.strip()))
79
+
80
+
81
+ def load_pk(pk_hex: str) -> Ed25519PublicKey:
82
+ return Ed25519PublicKey.from_public_bytes(bytes.fromhex(pk_hex.strip()))
83
+
84
+
85
+ # ============================================================
86
+ # Payload parsing
87
+ # ============================================================
88
+
89
+ def parse_payload_text(payload_text: str) -> Dict[str, Any]:
90
+ """Accept JSON or plain text; store as structured payload either way."""
91
+ txt = (payload_text or "").strip()
92
+ if not txt:
93
+ return {}
94
+ try:
95
+ v = json.loads(txt)
96
+ if isinstance(v, dict):
97
+ return v
98
+ return {"_value": v}
99
+ except Exception:
100
+ return {"_text": txt}
101
+
102
+
103
+ # ============================================================
104
+ # Log IO (locked)
105
+ # ============================================================
106
+
107
+ def read_jsonl(path: str) -> Tuple[List[Dict[str, Any]], int]:
108
+ """Read JSONL. Returns (records, corrupt_lines_count). Locked to avoid torn reads."""
109
+ if not os.path.exists(path):
110
+ return [], 0
111
+
112
+ lock = FileLock(_lock_path(path))
113
+ try:
114
+ with lock.acquire(timeout=READ_LOCK_TIMEOUT):
115
+ return _read_jsonl_unlocked(path)
116
+ except Timeout:
117
+ # If we cannot lock, refuse to guess.
118
+ return [], 0
119
+
120
+
121
+ def _read_jsonl_unlocked(path: str) -> Tuple[List[Dict[str, Any]], int]:
122
+ out: List[Dict[str, Any]] = []
123
+ corrupt = 0
124
+ with open(path, "r", encoding="utf-8") as f:
125
+ for ln in f:
126
+ ln = ln.strip()
127
+ if not ln:
128
+ continue
129
+ try:
130
+ obj = json.loads(ln)
131
+ if isinstance(obj, dict):
132
+ out.append(obj)
133
+ else:
134
+ corrupt += 1
135
+ except Exception:
136
+ corrupt += 1
137
+ return out, corrupt
138
+
139
+
140
+ def append_jsonl(path: str, obj: Dict[str, Any]) -> None:
141
+ """Append one JSON object as a single line. Locked by caller for atomic sequences."""
142
+ with open(path, "a", encoding="utf-8") as f:
143
+ f.write(json.dumps(obj, ensure_ascii=False) + "\n")
144
+
145
+
146
+ # ============================================================
147
+ # Core event model
148
+ # ============================================================
149
+
150
+ def make_event_core(
151
+ session_id: str,
152
+ seq: int,
153
+ event_type: str,
154
+ payload: Dict[str, Any],
155
+ parent_event_hash: str,
156
+ prev_event_hash: str,
157
+ meta: Dict[str, Any],
158
+ ) -> Dict[str, Any]:
159
+ return {
160
+ "spec": EVENT_SPEC,
161
+ "ts_utc": now_utc_iso(),
162
+ "session_id": session_id,
163
+ "seq": int(seq),
164
+ "event_type": (event_type or "note").strip(),
165
+ "parent_event_hash_sha256": (parent_event_hash or "").strip(),
166
+ "prev_event_hash_sha256": (prev_event_hash or "").strip() if prev_event_hash else ("0" * 64),
167
+ "payload": payload if isinstance(payload, dict) else {"_payload": payload},
168
+ "meta": meta if isinstance(meta, dict) else {},
169
+ }
170
+
171
+
172
+ def events_for_session(all_events: List[Dict[str, Any]], session_id: str) -> List[Dict[str, Any]]:
173
+ sid = (session_id or "").strip()
174
+ if not sid:
175
+ return []
176
+ return [
177
+ e for e in all_events
178
+ if isinstance(e, dict)
179
+ and e.get("spec") == EVENT_SPEC
180
+ and e.get("session_id") == sid
181
+ and "event_hash_sha256" in e
182
+ ]
183
+
184
+
185
+ def _session_has_ended(evs: List[Dict[str, Any]]) -> bool:
186
+ return any((e.get("event_type") == "session_end") for e in evs)
187
+
188
+
189
+ # ============================================================
190
+ # Append events (hash-chained; optional signature)
191
+ # ============================================================
192
+
193
+ def append_event(
194
+ log_path: str,
195
+ session_id: str,
196
+ event_type: str,
197
+ payload_text: str,
198
+ parent_event_hash: str,
199
+ sign_event: bool,
200
+ sk_hex: str,
201
+ model_id: str,
202
+ run_mode: str,
203
+ ) -> Tuple[Optional[Dict[str, Any]], str]:
204
+ sid = (session_id or "").strip()
205
+ if not sid:
206
+ return None, "Missing session_id."
207
+
208
+ lock = FileLock(_lock_path(log_path))
209
+ try:
210
+ with lock.acquire(timeout=WRITE_LOCK_TIMEOUT):
211
+ all_events, _corrupt = _read_jsonl_unlocked(log_path) if os.path.exists(log_path) else ([], 0)
212
+ evs = events_for_session(all_events, sid)
213
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
214
+
215
+ if _session_has_ended(evs):
216
+ return None, "Refused: session has ended (session_end already recorded)."
217
+
218
+ payload = parse_payload_text(payload_text)
219
+ meta = {
220
+ "model_id": (model_id or "unknown").strip(),
221
+ "run_mode": (run_mode or "unknown").strip(),
222
+ }
223
+
224
+ last = evs[-1] if evs else None
225
+ prev_hash = last.get("event_hash_sha256") if last else ("0" * 64)
226
+ seq = (int(last.get("seq", 0)) + 1) if last else 1
227
+
228
+ parent = (parent_event_hash or "").strip()
229
+ if not parent and seq > 1:
230
+ parent = prev_hash
231
+
232
+ core = make_event_core(
233
+ session_id=sid,
234
+ seq=seq,
235
+ event_type=event_type,
236
+ payload=payload,
237
+ parent_event_hash=parent,
238
+ prev_event_hash=prev_hash,
239
+ meta=meta,
240
+ )
241
 
242
+ event_hash = sha256_hex(canon(core))
243
+ event = dict(core)
244
+ event["event_hash_sha256"] = event_hash
245
+
246
+ if sign_event:
247
+ if not sk_hex or not sk_hex.strip():
248
+ return None, "Signing enabled but private key is missing."
249
+ try:
250
+ sk = load_sk(sk_hex)
251
+ sig = sk.sign(bytes.fromhex(event_hash))
252
+ event["signature_ed25519"] = sig.hex()
253
+ except Exception:
254
+ return None, "Failed to sign event (invalid private key?)."
255
+
256
+ append_jsonl(log_path, event)
257
+ return event, f"OK. Appended event #{seq} ({event_type}). Hash: {event_hash}"
258
+ except Timeout:
259
+ return None, "Busy: log file is locked. Retry."
260
+
261
+
262
+ def start_session(
263
+ log_path: str,
264
+ model_id: str,
265
+ run_mode: str,
266
+ notes: str,
267
+ sign_start: bool,
268
+ sk_hex: str,
269
+ ) -> Tuple[str, str]:
270
+ sid = uuid.uuid4().hex
271
+ payload = {"notes": (notes or "").strip()}
272
+
273
+ ev, msg = append_event(
274
+ log_path=log_path,
275
+ session_id=sid,
276
+ event_type="session_start",
277
+ payload_text=json.dumps(payload, ensure_ascii=False),
278
+ parent_event_hash="",
279
+ sign_event=sign_start,
280
+ sk_hex=sk_hex,
281
+ model_id=model_id,
282
+ run_mode=run_mode,
283
+ )
284
+ if not ev:
285
+ return "", msg
286
+ return sid, f"OK. Session started: {sid} (first hash: {ev['event_hash_sha256']})"
287
+
288
+
289
+ def finalise_session(
290
+ log_path: str,
291
+ session_id: str,
292
+ sign_anchor: bool,
293
+ sk_hex: str,
294
+ model_id: str,
295
+ run_mode: str,
296
+ ) -> Tuple[Optional[Dict[str, Any]], str]:
297
+ sid = (session_id or "").strip()
298
+ if not sid:
299
+ return None, "Missing session_id."
300
+
301
+ lock = FileLock(_lock_path(log_path))
302
+ try:
303
+ with lock.acquire(timeout=WRITE_LOCK_TIMEOUT):
304
+ all_events, _corrupt = _read_jsonl_unlocked(log_path) if os.path.exists(log_path) else ([], 0)
305
+ evs = events_for_session(all_events, sid)
306
+ if not evs:
307
+ return None, "No events found for this session."
308
+
309
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
310
+
311
+ if _session_has_ended(evs):
312
+ return None, "Refused: session already finalised (session_end exists)."
313
+
314
+ # Anchor must describe the chain BEFORE session_end (avoids circular dependency)
315
+ first_hash = evs[0]["event_hash_sha256"]
316
+ last_hash = evs[-1]["event_hash_sha256"]
317
+ count = len(evs)
318
+
319
+ root_core = {
320
+ "spec": ROOT_SPEC,
321
+ "session_id": sid,
322
+ "first_event_hash_sha256": first_hash,
323
+ "last_event_hash_sha256": last_hash,
324
+ "event_count": count,
325
+ }
326
+ root_hash = sha256_hex(canon(root_core))
327
+
328
+ anchor = dict(root_core)
329
+ anchor["root_hash_sha256"] = root_hash
330
+ anchor["created_utc"] = now_utc_iso()
331
+ anchor["model_id"] = (model_id or "unknown").strip()
332
+ anchor["run_mode"] = (run_mode or "unknown").strip()
333
+
334
+ if sign_anchor:
335
+ if not sk_hex or not sk_hex.strip():
336
+ return None, "Anchor signing enabled but private key is missing."
337
+ try:
338
+ sk = load_sk(sk_hex)
339
+ sig = sk.sign(bytes.fromhex(root_hash))
340
+ anchor["signature_ed25519"] = sig.hex()
341
+ except Exception:
342
+ return None, "Failed to sign anchor (invalid private key?)."
343
+
344
+ # Append session_end event containing the anchor (under same lock)
345
+ payload_text = json.dumps({"anchor": anchor}, ensure_ascii=False)
346
+
347
+ seq = int(evs[-1].get("seq", 0)) + 1
348
+ core = make_event_core(
349
+ session_id=sid,
350
+ seq=seq,
351
+ event_type="session_end",
352
+ payload=parse_payload_text(payload_text),
353
+ parent_event_hash=last_hash, # must point to last pre-end event
354
+ prev_event_hash=last_hash,
355
+ meta={
356
+ "model_id": (model_id or "unknown").strip(),
357
+ "run_mode": (run_mode or "unknown").strip(),
358
+ },
359
+ )
360
+ event_hash = sha256_hex(canon(core))
361
+ event = dict(core)
362
+ event["event_hash_sha256"] = event_hash
363
+
364
+ if sign_anchor:
365
+ try:
366
+ sk = load_sk(sk_hex)
367
+ sig = sk.sign(bytes.fromhex(event_hash))
368
+ event["signature_ed25519"] = sig.hex()
369
+ except Exception:
370
+ return None, "Failed to sign session_end event (invalid private key?)."
371
+
372
+ append_jsonl(log_path, event)
373
+
374
+ return anchor, f"OK. Session finalised. Root hash: {root_hash} (last event hash: {event_hash})"
375
+ except Timeout:
376
+ return None, "Busy: log file is locked. Retry."
377
+
378
+
379
+ # ============================================================
380
+ # Verification
381
+ # ============================================================
382
+
383
+ def verify_session_from_events(
384
+ evs: List[Dict[str, Any]],
385
+ session_id: str,
386
+ pk_hex: str = "",
387
+ require_signatures: bool = False,
388
+ ) -> Tuple[str, bool, str]:
389
+ sid = (session_id or "").strip()
390
+ if not sid:
391
+ return "Missing session_id.", False, ""
392
+
393
+ if not evs:
394
+ return "No events found for this session.", False, ""
395
+
396
+ report: List[str] = []
397
+ ok = True
398
+
399
+ pk = None
400
+ if require_signatures:
401
+ if not pk_hex or not pk_hex.strip():
402
+ return "Public key required to verify signatures.", False, ""
403
+ try:
404
+ pk = load_pk(pk_hex)
405
+ except Exception:
406
+ return "Invalid public key.", False, ""
407
+
408
+ expected_prev = "0" * 64
409
+ expected_seq = 1
410
+
411
+ for i, e in enumerate(evs):
412
+ for k in ("spec", "ts_utc", "session_id", "seq", "event_type", "prev_event_hash_sha256", "payload", "meta", "event_hash_sha256"):
413
+ if k not in e:
414
+ ok = False
415
+ report.append(f"[FAIL] Event {i+1}: missing field '{k}'.")
416
+ continue
417
+
418
+ if e.get("spec") != EVENT_SPEC:
419
+ ok = False
420
+ report.append(f"[FAIL] Bad spec at seq {e.get('seq')}.")
421
+ continue
422
+
423
+ if int(e.get("seq", -1)) != expected_seq:
424
+ ok = False
425
+ report.append(f"[FAIL] Seq mismatch: got {e.get('seq')} expected {expected_seq}.")
426
+ expected_seq += 1
427
+
428
+ if e.get("prev_event_hash_sha256") != expected_prev:
429
+ ok = False
430
+ report.append(
431
+ f"[FAIL] Chain broken at seq {e.get('seq')}: prev {short(e.get('prev_event_hash_sha256'))} expected {short(expected_prev)}."
432
+ )
433
 
434
+ core = {
435
+ "spec": e["spec"],
436
+ "ts_utc": e["ts_utc"],
437
+ "session_id": e["session_id"],
438
+ "seq": int(e["seq"]),
439
+ "event_type": e["event_type"],
440
+ "parent_event_hash_sha256": e.get("parent_event_hash_sha256", ""),
441
+ "prev_event_hash_sha256": e["prev_event_hash_sha256"],
442
+ "payload": e["payload"],
443
+ "meta": e["meta"],
444
+ }
445
+ h = sha256_hex(canon(core))
446
+ if h != e["event_hash_sha256"]:
447
+ ok = False
448
+ report.append(f"[FAIL] Hash mismatch at seq {e.get('seq')}: stored {short(e['event_hash_sha256'])} recomputed {short(h)}.")
449
+
450
+ if require_signatures:
451
+ sig_hex = (e.get("signature_ed25519") or "").strip()
452
+ if not sig_hex:
453
+ ok = False
454
+ report.append(f"[FAIL] Missing signature at seq {e.get('seq')}.")
455
+ else:
456
+ try:
457
+ pk.verify(bytes.fromhex(sig_hex), bytes.fromhex(e["event_hash_sha256"]))
458
+ except Exception:
459
+ ok = False
460
+ report.append(f"[FAIL] Bad signature at seq {e.get('seq')}.")
461
+
462
+ expected_prev = e["event_hash_sha256"]
463
+
464
+ # Anchor check: anchor describes chain BEFORE session_end (avoids circular dependency)
465
+ end_events = [e for e in evs if e.get("event_type") == "session_end"]
466
+ if end_events:
467
+ end_events.sort(key=lambda x: int(x.get("seq", 0)))
468
+ se = end_events[-1]
469
+ se_seq = int(se.get("seq", 0))
470
+
471
+ pre = [e for e in evs if int(e.get("seq", 0)) < se_seq]
472
+ pre.sort(key=lambda x: int(x.get("seq", 0)))
473
+
474
+ anchor = (se.get("payload") or {}).get("anchor")
475
+ if isinstance(anchor, dict) and pre:
476
+ first_hash = pre[0]["event_hash_sha256"]
477
+ last_hash = pre[-1]["event_hash_sha256"]
478
+ count = len(pre)
479
+
480
+ if (se.get("parent_event_hash_sha256") or "") != last_hash:
481
+ ok = False
482
+ report.append("[FAIL] session_end parent hash does not match last pre-end event.")
483
+
484
+ root_core = {
485
+ "spec": ROOT_SPEC,
486
+ "session_id": sid,
487
+ "first_event_hash_sha256": first_hash,
488
+ "last_event_hash_sha256": last_hash,
489
+ "event_count": count,
490
+ }
491
+ root_hash = sha256_hex(canon(root_core))
492
+
493
+ if anchor.get("root_hash_sha256") != root_hash:
494
+ ok = False
495
+ report.append("[FAIL] Session anchor root hash does not match pre-end event chain.")
496
+ else:
497
+ report.append("[OK] Session anchor matches pre-end event chain.")
498
+ else:
499
+ report.append("[WARN] session_end found but anchor payload is missing/invalid, or chain is empty.")
500
+
501
+ if ok:
502
+ report.insert(0, f"[PASS] Session verified: {len(evs)} events, chain intact.")
503
+ else:
504
+ report.insert(0, f"[FAIL] Session verification failed: {len(evs)} events checked.")
505
+
506
+ return ("PASS" if ok else "FAIL"), ok, "\n".join(report)
507
+
508
+
509
+ def verify_session(log_path: str, session_id: str, pk_hex: str, require_signatures: bool) -> Tuple[str, bool, str]:
510
+ all_events, _corrupt = read_jsonl(log_path)
511
+ evs = events_for_session(all_events, session_id)
512
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
513
+ return verify_session_from_events(evs, session_id, pk_hex=pk_hex, require_signatures=require_signatures)
514
+
515
+
516
+ # ============================================================
517
+ # Timeline + listing
518
+ # ============================================================
519
+
520
+ def session_timeline_rows(log_path: str, session_id: str) -> Tuple[List[List[Any]], str]:
521
+ sid = (session_id or "").strip()
522
+ if not sid:
523
+ return [], "Missing session_id."
524
+
525
+ all_events, _corrupt = read_jsonl(log_path)
526
+ evs = events_for_session(all_events, sid)
527
+ if not evs:
528
+ return [], "No events found."
529
+
530
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
531
+
532
+ rows: List[List[Any]] = []
533
+ for e in evs:
534
+ rows.append([
535
+ int(e.get("seq", 0)),
536
+ e.get("ts_utc", ""),
537
+ e.get("event_type", ""),
538
+ e.get("meta", {}).get("model_id", ""),
539
+ e.get("meta", {}).get("run_mode", ""),
540
+ e.get("parent_event_hash_sha256", ""),
541
+ e.get("prev_event_hash_sha256", ""),
542
+ e.get("event_hash_sha256", ""),
543
+ "yes" if e.get("signature_ed25519") else "no",
544
+ ])
545
+ return rows, f"Loaded {len(rows)} events."
546
+
547
+
548
+ def get_event_by_hash(log_path: str, session_id: str, event_hash: str) -> Tuple[Optional[Dict[str, Any]], str]:
549
+ sid = (session_id or "").strip()
550
+ h = (event_hash or "").strip()
551
+ if not sid or not h:
552
+ return None, "Missing session_id or event hash."
553
+
554
+ all_events, _corrupt = read_jsonl(log_path)
555
+ evs = events_for_session(all_events, sid)
556
+ for e in evs:
557
+ if e.get("event_hash_sha256") == h:
558
+ return e, "OK."
559
+ return None, "Not found."
560
+
561
+
562
+ def list_sessions(log_path: str) -> Tuple[List[str], str]:
563
+ all_events, corrupt = read_jsonl(log_path)
564
+ counts: Dict[str, int] = {}
565
+ for e in all_events:
566
+ if isinstance(e, dict) and e.get("spec") == EVENT_SPEC:
567
+ sid = e.get("session_id")
568
+ if sid:
569
+ counts[sid] = counts.get(sid, 0) + 1
570
+ sessions = sorted(counts.keys())
571
+ msg = f"Found {len(sessions)} sessions. Corrupt lines ignored: {corrupt}."
572
+ return sessions, msg
573
+
574
+
575
+ def diagnostics(log_path: str) -> Dict[str, Any]:
576
+ all_events, corrupt = read_jsonl(log_path)
577
+ size = os.path.getsize(log_path) if os.path.exists(log_path) else 0
578
+ sessions = set()
579
+ signed = 0
580
+ total = 0
581
+ for e in all_events:
582
+ if isinstance(e, dict) and e.get("spec") == EVENT_SPEC:
583
+ total += 1
584
+ if e.get("session_id"):
585
+ sessions.add(e["session_id"])
586
+ if e.get("signature_ed25519"):
587
+ signed += 1
588
+ return {
589
+ "log_path": log_path,
590
+ "exists": os.path.exists(log_path),
591
+ "bytes": size,
592
+ "total_events": total,
593
+ "sessions": len(sessions),
594
+ "signed_events": signed,
595
+ "corrupt_lines_ignored": corrupt,
596
+ }
597
+
598
+
599
+ # ============================================================
600
+ # Export bundle
601
+ # ============================================================
602
+
603
+ def export_session_bundle(log_path: str, session_id: str) -> Tuple[Optional[str], str]:
604
+ sid = (session_id or "").strip()
605
+ if not sid:
606
+ return None, "Missing session_id."
607
+
608
+ all_events, _corrupt = read_jsonl(log_path)
609
+ evs = events_for_session(all_events, sid)
610
+ if not evs:
611
+ return None, "No events found."
612
+
613
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
614
+
615
+ status, ok, report = verify_session_from_events(evs, sid, pk_hex="", require_signatures=False)
616
+
617
+ zip_name = f"rft_flight_bundle_{sid}.zip"
618
+ tmp_dir = "tmp_export"
619
+ os.makedirs(tmp_dir, exist_ok=True)
620
+
621
+ events_path = os.path.join(tmp_dir, f"{sid}_events.jsonl")
622
+ report_path = os.path.join(tmp_dir, f"{sid}_verify_report.txt")
623
+
624
+ with open(events_path, "w", encoding="utf-8") as f:
625
+ for e in evs:
626
+ f.write(json.dumps(e, ensure_ascii=False) + "\n")
627
+
628
+ with open(report_path, "w", encoding="utf-8") as f:
629
+ f.write(f"session_id: {sid}\n")
630
+ f.write(f"status: {status}\n")
631
+ f.write(f"ok: {ok}\n\n")
632
+ f.write(report + "\n")
633
+
634
+ with zipfile.ZipFile(zip_name, "w", compression=zipfile.ZIP_DEFLATED) as z:
635
+ z.write(events_path, arcname=os.path.basename(events_path))
636
+ z.write(report_path, arcname=os.path.basename(report_path))
637
+
638
+ return zip_name, f"OK. Exported {zip_name} ({len(evs)} events)."
639
+
640
+
641
+ # ============================================================
642
+ # Import bundle (third-party verification)
643
+ # ============================================================
644
+
645
+ def _read_jsonl_from_zip(z: zipfile.ZipFile, member: str) -> List[Dict[str, Any]]:
646
+ out: List[Dict[str, Any]] = []
647
+ raw_text = z.read(member).decode("utf-8", errors="replace")
648
+ for raw in raw_text.splitlines():
649
+ raw = raw.strip()
650
+ if not raw:
651
+ continue
652
+ try:
653
+ obj = json.loads(raw)
654
+ if isinstance(obj, dict):
655
+ out.append(obj)
656
+ except Exception:
657
+ continue
658
+ return out
659
+
660
+
661
+ def import_bundle_verify(
662
+ bundle_path: str,
663
+ pk_hex: str = "",
664
+ require_signatures: bool = False,
665
+ store_into_log: bool = False,
666
+ log_path: str = DEFAULT_LOG_PATH,
667
+ ) -> Tuple[str, bool, str, Optional[str]]:
668
+ if not bundle_path or not os.path.exists(bundle_path):
669
+ return "Missing bundle file.", False, "", None
670
+
671
+ try:
672
+ with zipfile.ZipFile(bundle_path, "r") as z:
673
+ members = z.namelist()
674
+ events_member = None
675
+ for m in members:
676
+ if m.endswith("_events.jsonl"):
677
+ events_member = m
678
+ break
679
+ if not events_member:
680
+ for m in members:
681
+ if m.endswith(".jsonl"):
682
+ events_member = m
683
+ break
684
+ if not events_member:
685
+ return "No .jsonl events file found in bundle.", False, "", None
686
+
687
+ evs = _read_jsonl_from_zip(z, events_member)
688
+
689
+ except Exception:
690
+ return "Failed to read bundle (invalid zip?).", False, "", None
691
+
692
+ sid = ""
693
+ for e in evs:
694
+ if e.get("spec") == EVENT_SPEC and e.get("session_id"):
695
+ sid = e["session_id"]
696
+ break
697
+ if not sid:
698
+ return "Bundle contains no valid flight events.", False, "", None
699
+
700
+ evs = [e for e in evs if e.get("spec") == EVENT_SPEC and e.get("session_id") == sid]
701
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
702
+
703
+ status, ok, report = verify_session_from_events(
704
+ evs, sid, pk_hex=pk_hex, require_signatures=require_signatures
705
+ )
706
 
707
+ stored_msg = None
708
+ if store_into_log and ok:
709
+ lock = FileLock(_lock_path(log_path))
710
+ try:
711
+ with lock.acquire(timeout=WRITE_LOCK_TIMEOUT):
712
+ for e in evs:
713
+ append_jsonl(log_path, e)
714
+ stored_msg = "Stored into local flightlog.jsonl."
715
+ except Timeout:
716
+ stored_msg = "Could not store: log file is locked."
717
+
718
+ return status, ok, report, stored_msg