corrlog-core 0.1.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,112 @@
1
+ """corrlog-autogen — drop-in correction log for AutoGen agents.
2
+
3
+ AutoGen has NO merged tool-call middleware as of this build (verified against the
4
+ cloned source). The seams we need — `BaseTool.run_json()` / `Workbench.call_tool()` —
5
+ are exactly what the still-open proposals target (#7405 GuardrailProvider, #7353 AAR
6
+ receipts). So this adapter wraps `run_json` rather than relying on a shipped hook.
7
+
8
+ Import-safe: if autogen is not installed, `is_available()` returns False and the
9
+ wrapper class still imports.
10
+
11
+ Usage (when autogen is installed):
12
+ from corrlog_autogen import GuardedTool
13
+ tool = GuardedTool(my_autogen_tool, sink, private_key, agent_id="agent-1")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from corrlog_core import record, retract
21
+
22
+ try:
23
+ from autogen_core.tools import BaseTool # type: ignore
24
+ _AUTOGEN_AVAILABLE = True
25
+ except Exception: # pragma: no cover
26
+ _AUTOGEN_AVAILABLE = False
27
+
28
+
29
+ def is_available() -> bool:
30
+ return _AUTOGEN_AVAILABLE
31
+
32
+
33
+ class GuardedTool:
34
+ """Wraps an AutoGen tool, adding before/after correction-logging around `run_json`.
35
+
36
+ Structured to match the proposed GuardrailProvider signature from #7405, so it
37
+ upgrades cleanly when that lands upstream.
38
+ """
39
+
40
+ def __init__(self, tool, sink, private_key, agent_id: str, principal_id: str = "organization") -> None:
41
+ self._tool = tool
42
+ self.sink = sink
43
+ self.private_key = private_key
44
+ self.agent_id = agent_id
45
+ self.principal_id = principal_id
46
+
47
+ def __getattr__(self, name: str):
48
+ # Delegate everything else to the wrapped tool.
49
+ return getattr(self._tool, name)
50
+
51
+ @property
52
+ def name(self) -> str:
53
+ return getattr(self._tool, "name", "unknown_tool")
54
+
55
+ async def run_json(self, args, cancellation_token=None, call_id=None):
56
+ tool_name = self.name
57
+ rec = record(
58
+ agent_id=self.agent_id,
59
+ principal_id=self.principal_id,
60
+ action_type=f"autogen.tool.{tool_name}",
61
+ action_args=args if isinstance(args, dict) else {"args": args},
62
+ private_key=self.private_key,
63
+ metadata={"call_id": call_id} if call_id else None,
64
+ )
65
+ self.sink.append(rec)
66
+
67
+ try:
68
+ result = await self._tool.run_json(args, cancellation_token, call_id)
69
+ except Exception as e: # noqa: BLE001
70
+ corr = retract(
71
+ prior_record=rec,
72
+ reason=f"tool raised: {e}",
73
+ trigger="check_failed",
74
+ agent_id=self.agent_id,
75
+ private_key=self.private_key,
76
+ principal_id=self.principal_id,
77
+ fix_type="replace",
78
+ fix_note=f"autogen tool {tool_name} raised",
79
+ )
80
+ self.sink.append(corr)
81
+ raise
82
+
83
+ # Result-level error detection (best-effort, framework-dependent shape).
84
+ if isinstance(result, dict) and result.get("error"):
85
+ corr = retract(
86
+ prior_record=rec,
87
+ reason=str(result["error"]),
88
+ trigger="check_failed",
89
+ agent_id=self.agent_id,
90
+ private_key=self.private_key,
91
+ principal_id=self.principal_id,
92
+ fix_type="replace",
93
+ fix_note=f"autogen tool {tool_name} errored",
94
+ )
95
+ self.sink.append(corr)
96
+
97
+ return result
98
+
99
+ def mark_wrong(self, prior_record: dict[str, Any], reason: str, fix_note: str | None = None) -> dict[str, Any]:
100
+ """Human-flag a prior record as wrong -> signed correction."""
101
+ corr = retract(
102
+ prior_record=prior_record,
103
+ reason=reason,
104
+ trigger="human_flagged",
105
+ agent_id=self.agent_id,
106
+ private_key=self.private_key,
107
+ principal_id=self.principal_id,
108
+ fix_type="replace",
109
+ fix_note=fix_note,
110
+ )
111
+ self.sink.append(corr)
112
+ return corr
@@ -0,0 +1,8 @@
1
+ """corrlog-claude-code — plugin surface. The actual hook logic is in cli.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def is_available() -> bool:
7
+ """Claude Code hooks are external processes — always 'available' if python is."""
8
+ return True
@@ -0,0 +1,147 @@
1
+ """corrlog-claude-code — CLI for Claude Code hooks.
2
+
3
+ Invoked by hooks/hooks.json as an external process. Reads the hook's stdin JSON,
4
+ emits a signed correction-log record.
5
+
6
+ Modes:
7
+ - PreToolUse : hash the tool input, mint an action id (emit to stderr-free stdout)
8
+ - PostToolUse : hash the tool result, sign the action record
9
+ - human-flag : `corrlog human-flag --receipt <id> --reason "..."` to flag wrong
10
+
11
+ The key lives in $CORRLOG_KEY_PATH (a PEM/raw Ed25519 private key file).
12
+ Records append to $CORRLOG_SINK (JSONL path) or ./corrections.jsonl.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
+
24
+ from corrlog_core import generate_keypair, record, retract, JsonlSink
25
+ from cryptography.hazmat.primitives import serialization
26
+ from cryptography.hazmat.primitives.asymmetric import ed25519
27
+
28
+
29
+ def _load_or_create_key(path: str):
30
+ if path and os.path.exists(path):
31
+ with open(path, "rb") as f:
32
+ return serialization.load_pem_private_key(f.read(), password=None)
33
+ priv = ed25519.Ed25519PrivateKey.generate()
34
+ if path:
35
+ with open(path, "wb") as f:
36
+ f.write(priv.private_bytes(
37
+ serialization.Encoding.PEM,
38
+ serialization.PrivateFormat.PKCS8,
39
+ serialization.NoEncryption(),
40
+ ))
41
+ return priv
42
+
43
+
44
+ def _sink_path() -> str:
45
+ return os.environ.get("CORRLOG_SINK", os.path.join(os.getcwd(), "corrections.jsonl"))
46
+
47
+
48
+ def _key_path() -> str:
49
+ return os.environ.get("CORRLOG_KEY_PATH", os.path.join(os.getcwd(), "corrlog_key.pem"))
50
+
51
+
52
+ def _agent_id() -> str:
53
+ return os.environ.get("CORRLOG_AGENT_ID", "claude-code-agent")
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ argv = argv if argv is not None else sys.argv[1:]
58
+
59
+ # Subcommand: human-flag
60
+ if argv and argv[0] == "human-flag":
61
+ p = argparse.ArgumentParser(prog="corrlog human-flag")
62
+ p.add_argument("--receipt", required=True)
63
+ p.add_argument("--reason", required=True)
64
+ p.add_argument("--fix-note")
65
+ args = p.parse_args(argv[1:])
66
+ priv = _load_or_create_key(_key_path())
67
+ sink = JsonlSink(_sink_path())
68
+ # Find the prior record.
69
+ prior = sink.get(args.receipt)
70
+ if prior is None:
71
+ print(json.dumps({"ok": False, "error": f"receipt {args.receipt} not found"}), file=sys.stderr)
72
+ return 1
73
+ corr = retract(
74
+ prior_record=prior, reason=args.reason, trigger="human_flagged",
75
+ agent_id=_agent_id(), private_key=priv, fix_type="replace", fix_note=args.fix_note,
76
+ )
77
+ sink.append(corr)
78
+ print(json.dumps({"ok": True, "correctionId": corr["correctionId"]}))
79
+ return 0
80
+
81
+ # Hook mode: read stdin JSON (Claude Code hook contract).
82
+ try:
83
+ data = json.load(sys.stdin)
84
+ except Exception:
85
+ data = {}
86
+
87
+ hook = data.get("hook_event_name", "")
88
+ tool_name = data.get("tool_name", "")
89
+ tool_input = data.get("tool_input", {})
90
+
91
+ priv = _load_or_create_key(_key_path())
92
+ sink = JsonlSink(_sink_path())
93
+
94
+ if hook == "PreToolUse":
95
+ rec = record(
96
+ agent_id=_agent_id(), action_type=f"claude.tool.{tool_name}",
97
+ action_args=tool_input if isinstance(tool_input, dict) else {"input": tool_input},
98
+ private_key=priv,
99
+ )
100
+ sink.append(rec)
101
+ # Allow the call; stash the action id in a sidecar file for PostToolUse.
102
+ _sidecar = os.path.join(os.getcwd(), ".corrlog_inflight.json")
103
+ try:
104
+ inflight = json.load(open(_sidecar)) if os.path.exists(_sidecar) else {}
105
+ inflight[tool_name] = rec["correctionId"]
106
+ json.dump(inflight, open(_sidecar, "w"))
107
+ except Exception:
108
+ pass
109
+ print(json.dumps({"continue": True}))
110
+
111
+ elif hook == "PostToolUse":
112
+ tool_result = data.get("tool_result", "")
113
+ _sidecar = os.path.join(os.getcwd(), ".corrlog_inflight.json")
114
+ prior_id = None
115
+ try:
116
+ inflight = json.load(open(_sidecar)) if os.path.exists(_sidecar) else {}
117
+ prior_id = inflight.pop(tool_name, None)
118
+ json.dump(inflight, open(_sidecar, "w"))
119
+ except Exception:
120
+ pass
121
+ rec = record(
122
+ agent_id=_agent_id(), action_type=f"claude.tool.{tool_name}",
123
+ action_args=tool_input if isinstance(tool_input, dict) else {"input": tool_input},
124
+ action_result=tool_result, private_key=priv,
125
+ )
126
+ sink.append(rec)
127
+ # Detect error in result -> check_failed correction.
128
+ if isinstance(tool_result, str) and any(
129
+ m in tool_result.lower() for m in ("error", "failed", "exception", "denied")
130
+ ):
131
+ corr = retract(
132
+ prior_record=rec, reason="tool returned error", trigger="check_failed",
133
+ agent_id=_agent_id(), private_key=priv, fix_type="replace",
134
+ fix_note=f"claude tool {tool_name} errored",
135
+ )
136
+ sink.append(corr)
137
+ print(json.dumps({"continue": True}))
138
+
139
+ else:
140
+ # Unhandled hook event: no-op, allow.
141
+ print(json.dumps({"continue": True}))
142
+
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "*",
6
+ "command": "python3 -m corrlog_claude_code.cli"
7
+ }
8
+ ],
9
+ "PostToolUse": [
10
+ {
11
+ "matcher": "*",
12
+ "command": "python3 -m corrlog_claude_code.cli"
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,385 @@
1
+ """corrlog-core — the Agent Correction Record (ACR) core.
2
+
3
+ Framework-independent implementation of the ACR v1.0 spec (see SPEC.md):
4
+ sign, verify, record, retract. Ed25519 over RFC 8785 (JCS) canonical JSON.
5
+
6
+ The only runtime dependency is `cryptography` (for Ed25519).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Protocol
17
+
18
+ from cryptography.exceptions import InvalidSignature
19
+ from cryptography.hazmat.primitives import serialization
20
+
21
+ __version__ = "0.1.0"
22
+ from cryptography.hazmat.primitives.asymmetric import ed25519
23
+
24
+ CANONICALIZATION = "RFC8785"
25
+ VALID_TRIGGERS = ("supersede", "check_failed", "human_flagged", "self_correction")
26
+ VALID_FIX_TYPES = ("replace", "delete", "rollback", "noop", "other")
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Canonical JSON per RFC 8785 (JCS — JSON Canonicalization Scheme)
31
+ # ---------------------------------------------------------------------------
32
+ def canonical_json(obj: dict[str, Any]) -> bytes:
33
+ """Return RFC 8785 (JCS) canonical bytes.
34
+
35
+ RFC 8785 requires: recursive key sort by UTF-16 code unit order, no
36
+ insignificant whitespace, and specific string escaping — control
37
+ characters and non-ASCII MUST be escaped as ``\\uXXXX``/surrogate pairs so
38
+ every conforming implementation (Python, Rust, Go, TS) produces
39
+ byte-identical output. Python's ``json.dumps`` with ``ensure_ascii=True``
40
+ and ``separators=(",", ":")`` satisfies this for strings; numbers MUST be
41
+ serialized per RFC 8785 §3.2.3 (ES6 Number::toString) — this reference
42
+ avoids the float ambiguity by representing non-integer values as strings
43
+ (see `record`/`retract`), matching AAR's practice.
44
+ """
45
+ def _sorted(o: Any) -> Any:
46
+ if isinstance(o, dict):
47
+ # sort_keys sorts by Unicode code point, which matches UTF-16
48
+ # code unit order for the BMP (all keys here are ASCII-safe).
49
+ return {k: _sorted(v) for k, v in sorted(o.items())}
50
+ if isinstance(o, list):
51
+ return [_sorted(v) for v in o]
52
+ return o
53
+
54
+ return json.dumps(
55
+ _sorted(obj), separators=(",", ":"), ensure_ascii=True, sort_keys=True
56
+ ).encode("utf-8")
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Key helpers
61
+ # ---------------------------------------------------------------------------
62
+ def generate_keypair() -> tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
63
+ """Return (private_key, public_key) as cryptography Ed25519 objects."""
64
+ priv = ed25519.Ed25519PrivateKey.generate()
65
+ return priv, priv.public_key()
66
+
67
+
68
+ def public_key_b64url(pub: ed25519.Ed25519PublicKey) -> str:
69
+ """Base64url (no padding) encoding of the raw Ed25519 public key bytes."""
70
+ raw = pub.public_bytes(
71
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
72
+ )
73
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
74
+
75
+
76
+ def load_private_key(seed: str) -> ed25519.Ed25519PrivateKey:
77
+ """Load an Ed25519 private key from a 32-byte seed string.
78
+
79
+ Accepts the seed as base64url (no padding) or hex. This is the loading
80
+ counterpart to ``generate_keypair`` — adapters use it to turn an operator's
81
+ secret (from an env var) into a signing key without inventing their own
82
+ serialization.
83
+ """
84
+ s = seed.strip()
85
+ if s.startswith(("sk_", "priv_", "ed25519:")):
86
+ # allow a small prefix for readability; strip it
87
+ s = s.split(":", 1)[-1] if ":" in s else s[2:] if s.startswith("sk_") else s[5:]
88
+ try:
89
+ raw = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
90
+ except Exception:
91
+ raw = b""
92
+ if len(raw) != 32:
93
+ # fall back to hex
94
+ try:
95
+ raw = bytes.fromhex(s)
96
+ except Exception:
97
+ raw = b""
98
+ if len(raw) != 32:
99
+ raise ValueError(
100
+ "seed must decode to exactly 32 bytes (base64url or hex)"
101
+ )
102
+ return ed25519.Ed25519PrivateKey.from_private_bytes(raw)
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Record construction
107
+ # ---------------------------------------------------------------------------
108
+ def _hash_object(data: bytes) -> dict[str, str]:
109
+ return {"alg": "sha256", "digest": _b64url(hashlib.sha256(data).digest())}
110
+
111
+
112
+ def _b64url(data: bytes) -> str:
113
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
114
+
115
+
116
+ def record(
117
+ *,
118
+ agent_id: str,
119
+ agent_name: str | None = None,
120
+ principal_id: str = "unknown",
121
+ principal_type: str = "organization",
122
+ action_type: str,
123
+ action_target: str | None = None,
124
+ action_args: Any = None,
125
+ action_result: Any = None,
126
+ private_key: ed25519.Ed25519PrivateKey,
127
+ kid: str | None = None,
128
+ metadata: dict[str, Any] | None = None,
129
+ correction_id: str | None = None,
130
+ timestamp: str | None = None,
131
+ ) -> dict[str, Any]:
132
+ """Build a signed ACR-compatible *action* record (the receipt a correction can supersede).
133
+
134
+ This is the lightweight action record; corrections are built with `retract`.
135
+
136
+ ``correction_id`` and ``timestamp`` are OPTIONAL and exist for deterministic
137
+ test vectors (reproducible records, cross-implementation verification).
138
+ When omitted they default to a fresh UUID / current UTC time.
139
+ """
140
+ ts = timestamp if timestamp is not None else datetime.now(timezone.utc).isoformat()
141
+ public_key = private_key.public_key()
142
+ pk_b64 = public_key_b64url(public_key)
143
+ if kid is None:
144
+ kid = pk_b64
145
+
146
+ body: dict[str, Any] = {
147
+ "correctionId": correction_id if correction_id is not None else str(uuid.uuid4()),
148
+ "agent": {"id": agent_id, "publicKey": pk_b64},
149
+ "principal": {"id": principal_id, "type": principal_type},
150
+ "action": {"type": action_type},
151
+ "reason": "",
152
+ "trigger": "supersede", # placeholder; corrected on retract
153
+ "fix": {"type": "noop"},
154
+ "timestamp": ts,
155
+ "metadata": metadata or {},
156
+ }
157
+ if agent_name:
158
+ body["agent"]["name"] = agent_name
159
+ if action_target:
160
+ body["action"]["target"] = action_target
161
+
162
+ # Store hashes of args/result (privacy-preserving) in metadata.
163
+ if action_args is not None:
164
+ body["metadata"]["inputHash"] = _hash_object(
165
+ canonical_json(action_args) if isinstance(action_args, dict) else str(action_args).encode()
166
+ )
167
+ if action_result is not None:
168
+ body["metadata"]["outputHash"] = _hash_object(
169
+ canonical_json(action_result) if isinstance(action_result, dict) else str(action_result).encode()
170
+ )
171
+
172
+ _sign(body, private_key, kid)
173
+ return body
174
+
175
+
176
+ def retract(
177
+ *,
178
+ prior_record: dict[str, Any],
179
+ reason: str,
180
+ trigger: str,
181
+ agent_id: str,
182
+ private_key: ed25519.Ed25519PrivateKey,
183
+ principal_id: str = "unknown",
184
+ principal_type: str = "organization",
185
+ fix_type: str = "replace",
186
+ fix_note: str | None = None,
187
+ corrected_content: Any = None,
188
+ kid: str | None = None,
189
+ metadata: dict[str, Any] | None = None,
190
+ correction_id: str | None = None,
191
+ timestamp: str | None = None,
192
+ ) -> dict[str, Any]:
193
+ """Build a signed correction record that supersedes `prior_record`.
194
+
195
+ `prior_record` is the action record (or prior correction) being amended.
196
+ The supersedes pointer is a SHA-256 content hash of the prior record's
197
+ canonical bytes — not just an id — so the chain is verifiable offline.
198
+
199
+ ``correction_id`` and ``timestamp`` are OPTIONAL and exist for deterministic
200
+ test vectors (reproducible records, cross-implementation verification).
201
+ """
202
+ if trigger not in VALID_TRIGGERS:
203
+ raise ValueError(f"trigger must be one of {VALID_TRIGGERS}, got {trigger!r}")
204
+ if fix_type not in VALID_FIX_TYPES:
205
+ raise ValueError(f"fix.type must be one of {VALID_FIX_TYPES}, got {fix_type!r}")
206
+
207
+ ts = timestamp if timestamp is not None else datetime.now(timezone.utc).isoformat()
208
+ public_key = private_key.public_key()
209
+ pk_b64 = public_key_b64url(public_key)
210
+ if kid is None:
211
+ kid = pk_b64
212
+
213
+ prior_digest = _hash_object(canonical_json(prior_record))
214
+
215
+ body: dict[str, Any] = {
216
+ "correctionId": correction_id if correction_id is not None else str(uuid.uuid4()),
217
+ "agent": {"id": agent_id, "publicKey": pk_b64},
218
+ "principal": {"id": principal_id, "type": principal_type},
219
+ "supersedes": {
220
+ "receiptId": prior_record.get("correctionId") or prior_record.get("receiptId"),
221
+ "digest": prior_digest,
222
+ },
223
+ "action": {"type": prior_record.get("action", {}).get("type", "unknown")},
224
+ "reason": reason,
225
+ "trigger": trigger,
226
+ "fix": {"type": fix_type},
227
+ "timestamp": ts,
228
+ "metadata": metadata or {},
229
+ }
230
+ if fix_note:
231
+ body["fix"]["note"] = fix_note
232
+ if corrected_content is not None:
233
+ body["fix"]["contentHash"] = _hash_object(
234
+ canonical_json(corrected_content)
235
+ if isinstance(corrected_content, dict)
236
+ else str(corrected_content).encode()
237
+ )
238
+
239
+ _sign(body, private_key, kid)
240
+ return body
241
+
242
+
243
+ def _sign(body: dict[str, Any], private_key: ed25519.Ed25519PrivateKey, kid: str) -> None:
244
+ body["signature"] = {
245
+ "alg": "Ed25519",
246
+ "kid": kid,
247
+ "publicKey": public_key_b64url(private_key.public_key()),
248
+ "canonicalization": CANONICALIZATION,
249
+ "sig": "",
250
+ }
251
+ # Canonicalize with sig removed (empty), then sign.
252
+ sig_bytes = private_key.sign(canonical_json(body))
253
+ body["signature"]["sig"] = _b64url(sig_bytes)
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Verification
258
+ # ---------------------------------------------------------------------------
259
+ def verify(record: dict[str, Any], public_key: ed25519.Ed25519PublicKey | None = None) -> bool:
260
+ """Verify an ACR record's Ed25519 signature.
261
+
262
+ If `public_key` is None, the key embedded in `record["signature"]["publicKey"]`
263
+ is used (self-authenticating). Pass a key explicitly to pin trust to a known key.
264
+ """
265
+ sig_meta = record.get("signature") or {}
266
+ if sig_meta.get("alg") != "Ed25519":
267
+ return False
268
+ if sig_meta.get("canonicalization") != CANONICALIZATION:
269
+ return False
270
+
271
+ sig_b64 = sig_meta.get("sig") or ""
272
+ if not sig_b64:
273
+ return False
274
+
275
+ try:
276
+ sig_bytes = base64.urlsafe_b64decode(sig_b64 + "=" * (-len(sig_b64) % 4))
277
+ except Exception:
278
+ return False
279
+
280
+ if public_key is None:
281
+ pk_b64 = sig_meta.get("publicKey") or record.get("agent", {}).get("publicKey")
282
+ if not pk_b64:
283
+ return False
284
+ try:
285
+ raw = base64.urlsafe_b64decode(pk_b64 + "=" * (-len(pk_b64) % 4))
286
+ public_key = ed25519.Ed25519PublicKey.from_public_bytes(raw)
287
+ except Exception:
288
+ return False
289
+
290
+ # Re-canonicalize with sig removed.
291
+ payload = {k: v for k, v in record.items() if k != "signature"}
292
+ payload["signature"] = {**sig_meta, "sig": ""}
293
+
294
+ try:
295
+ public_key.verify(sig_bytes, canonical_json(payload))
296
+ return True
297
+ except InvalidSignature:
298
+ return False
299
+ except Exception:
300
+ return False
301
+
302
+
303
+ def verify_chain(records: list[dict[str, Any]], public_key: ed25519.Ed25519PublicKey | None = None) -> bool:
304
+ """Verify a chain of records: each signature valid, and each correction's
305
+ `supersedes.digest` matches the canonical bytes of the prior record."""
306
+ for i, rec in enumerate(records):
307
+ if not verify(rec, public_key):
308
+ return False
309
+ if i == 0:
310
+ continue
311
+ sup = rec.get("supersedes")
312
+ if not sup:
313
+ # A chain where a later record doesn't supersede is broken.
314
+ return False
315
+ expected_digest = sup.get("digest", {}).get("digest")
316
+ if not expected_digest:
317
+ return False
318
+ actual_digest = _b64url(hashlib.sha256(canonical_json(records[i - 1])).digest())
319
+ if actual_digest != expected_digest:
320
+ return False
321
+ return True
322
+
323
+
324
+ def tamper_evident(record: dict[str, Any]) -> bool:
325
+ """True if any mutation of the signed body invalidates the signature."""
326
+ # A record is tamper-evident by construction; this checks the signature
327
+ # still holds for the current body (i.e. it has NOT been mutated).
328
+ return verify(record)
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Pluggable sink
333
+ # ---------------------------------------------------------------------------
334
+ class Sink(Protocol):
335
+ def append(self, record: dict[str, Any]) -> None: ...
336
+ def get(self, correction_id: str) -> dict[str, Any] | None: ...
337
+ def all(self) -> list[dict[str, Any]]: ...
338
+
339
+
340
+ class MemorySink:
341
+ """In-memory append-only sink (for demos/tests)."""
342
+
343
+ def __init__(self) -> None:
344
+ self._records: list[dict[str, Any]] = []
345
+
346
+ def append(self, record: dict[str, Any]) -> None:
347
+ self._records.append(record)
348
+
349
+ def get(self, correction_id: str) -> dict[str, Any] | None:
350
+ for r in self._records:
351
+ if r.get("correctionId") == correction_id:
352
+ return r
353
+ return None
354
+
355
+ def all(self) -> list[dict[str, Any]]:
356
+ return list(self._records)
357
+
358
+
359
+ class JsonlSink:
360
+ """Append-only JSONL file sink — durable, hash-chained by file order."""
361
+
362
+ def __init__(self, path: str) -> None:
363
+ self.path = path
364
+
365
+ def append(self, record: dict[str, Any]) -> None:
366
+ with open(self.path, "a", encoding="utf-8") as f:
367
+ f.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")
368
+
369
+ def get(self, correction_id: str) -> dict[str, Any] | None:
370
+ for r in self.all():
371
+ if r.get("correctionId") == correction_id:
372
+ return r
373
+ return None
374
+
375
+ def all(self) -> list[dict[str, Any]]:
376
+ out: list[dict[str, Any]] = []
377
+ try:
378
+ with open(self.path, "r", encoding="utf-8") as f:
379
+ for line in f:
380
+ line = line.strip()
381
+ if line:
382
+ out.append(json.loads(line))
383
+ except FileNotFoundError:
384
+ pass
385
+ return out