diff --git a/src/git_agent/quilt_emit.py b/src/git_agent/quilt_emit.py new file mode 100644 index 0000000..7e7e270 --- /dev/null +++ b/src/git_agent/quilt_emit.py @@ -0,0 +1,245 @@ +""" +Vessel-Quilt emitter — git-agent's lifecycle events as quilt 5-opcode WAL records. + +The fleet's quilt kernel speaks five opcodes (BIND / LINK / EFFECT / VIEW / TICK, +plus FORGET). git-agent's vessel already produces structured lifecycle events +(session start, worklog entries, task outcomes, career promotions, fences, +skills, snapshots, heartbeats, session end) — this module is the translation +and validation layer that makes git-agent the first quilt-native fleet agent. + +Every ingested event is: + 1. validated against the event schema (enforced here in stdlib code; the + canonical JSON Schema lives beside the producer at + ``git_agent/schemas/event.schema.json`` for external consumers), + 2. mapped to exactly one quilt opcode line, + 3. appended to a fnv1a hash-chained JSONL WAL (default ``~/.git-agent/quilt.jsonl``), + 4. folded into a reducer state that ``replay()`` can reconstruct from the WAL alone. + +Guarantees: invalid events raise ``QuiltValidationError`` (never silently +dropped, never crash the ingest loop); duplicate ``event_id`` deliveries are +idempotent (at-least-once ingest → exactly-once WAL). +""" + +from __future__ import annotations + +import datetime +import json +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +SCHEMA_PATH = Path(__file__).parent / "schemas" / "event.schema.json" + +DEFAULT_WAL = Path.home() / ".git-agent" / "quilt.jsonl" + +EVENT_TYPES = ( + "session_start", "worklog", "task_completion", "promotion", + "fence", "skill", "snapshot", "heartbeat", "session_end", +) + +# per-type required fields beyond the universal (event_id, type, timestamp) +REQUIRED: Dict[str, tuple] = { + "session_start": ("name", "designation", "version"), + "worklog": ("action", "target", "summary", "outcome"), + "task_completion": ("success",), + "promotion": ("from_stage", "to_stage"), + "fence": ("fence_name",), + "skill": ("skill",), + "snapshot": ("stage",), + "heartbeat": (), + "session_end": (), +} + +OUTCOMES = ("success", "failure", "partial") +_TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") + + +class QuiltValidationError(ValueError): + """Raised when an event fails schema validation. Precise about the why.""" + + +def fnv1a(s: str) -> str: + """64-bit FNV-1a, hex-encoded (16 chars). Same integrity family the fleet's + rate limiter and quilt WAL use — fast, deterministic, non-cryptographic.""" + h = 0xCBF29CE484222325 + for b in s.encode("utf-8"): + h ^= b + h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF + return f"{h:016x}" + + +def _canonical(obj: Dict[str, Any]) -> str: + return json.dumps(obj, sort_keys=True, separators=(",", ":")) + + +def _validate(event: Dict[str, Any]) -> None: + if not isinstance(event, dict): + raise QuiltValidationError("event must be a JSON object") + for field in ("event_id", "type", "timestamp"): + if field not in event: + raise QuiltValidationError(f"missing required field: {field!r}") + if not isinstance(event["event_id"], str) or not event["event_id"]: + raise QuiltValidationError("event_id must be a non-empty string") + if event["type"] not in EVENT_TYPES: + raise QuiltValidationError( + f"unknown event type: {event['type']!r} (expected one of {', '.join(EVENT_TYPES)})") + if not (isinstance(event["timestamp"], str) and _TS_RE.match(event["timestamp"])): + raise QuiltValidationError( + f"timestamp must be ISO-8601 (YYYY-MM-DDTHH:MM:SS...), got {event['timestamp']!r}") + for field in REQUIRED[event["type"]]: + if field not in event: + raise QuiltValidationError( + f"{event['type']} event missing required field: {field!r}") + if event["type"] == "worklog": + if event["outcome"] not in OUTCOMES: + raise QuiltValidationError( + f"worklog outcome must be one of {OUTCOMES}, got {event['outcome']!r}") + + +def _map(event: Dict[str, Any]) -> Dict[str, Any]: + """One vessel event → one quilt opcode line (minus chain fields).""" + t = event["type"] + if t == "session_start": + return {"op": "BIND", "cell": "vessel/identity", + "args": {"name": event["name"], "designation": event["designation"], + "version": event["version"], "domains": event.get("domains", [])}} + if t == "worklog": + return {"op": "LINK", "cell": "vessel/worklog", + "args": {"action": event["action"], "target": event["target"], + "summary": event["summary"], "outcome": event["outcome"]}} + if t == "task_completion": + return {"op": "EFFECT", "cell": "vessel/career", + "args": {"kind": "task", "success": bool(event["success"])}} + if t == "promotion": + return {"op": "EFFECT", "cell": "vessel/career", + "args": {"kind": "promotion", "from_stage": event["from_stage"], + "to_stage": event["to_stage"]}} + if t == "fence": + return {"op": "EFFECT", "cell": "vessel/career", + "args": {"kind": "fence", "fence_name": event["fence_name"]}} + if t == "skill": + return {"op": "BIND", "cell": "vessel/skills", "args": {"skill": event["skill"]}} + if t == "snapshot": + return {"op": "VIEW", "cell": "vessel/state", + "args": {"stage": event["stage"], + "total_tasks_completed": event.get("total_tasks_completed"), + "total_tasks_failed": event.get("total_tasks_failed"), + "worklog_len": event.get("worklog_len")}} + if t == "heartbeat": + return {"op": "TICK", "cell": "vessel/heartbeat", "args": {}} + if t == "session_end": + return {"op": "FORGET", "cell": "vessel/session", + "args": {"archived": bool(event.get("archived", True))}} + raise QuiltValidationError(f"unmapped event type: {t}") # pragma: no cover + + +class QuiltEmitter: + """Ingest vessel lifecycle events → append hash-chained quilt WAL lines.""" + + def __init__(self, wal_path: Optional[Path] = None): + self.wal_path = Path(wal_path) if wal_path else DEFAULT_WAL + self._seen: set = set() + self._state: Dict[str, Any] = self._empty_state() + if self.wal_path.exists(): + for line in self._read_lines(): + self._seen.add(line["event_id"]) + self._fold(line, self._state) + + @staticmethod + def _empty_state() -> Dict[str, Any]: + return {"identity": None, "tasks_completed": 0, "tasks_failed": 0, + "promotions": [], "fences": [], "skills": [], "worklog": 0, + "last_tick": None, "archived": False, "lines": 0} + + def _read_lines(self) -> List[Dict[str, Any]]: + if not self.wal_path.exists(): + return [] + out = [] + for raw in self.wal_path.read_text().splitlines(): + if raw.strip(): + out.append(json.loads(raw)) + return out + + def wal(self) -> List[Dict[str, Any]]: + return self._read_lines() + + def ingest(self, event: Dict[str, Any]) -> Dict[str, Any]: + _validate(event) + if event["event_id"] in self._seen: + for line in self._read_lines(): # idempotent no-op: return the existing line + if line["event_id"] == event["event_id"]: + return line + raise QuiltValidationError( # pragma: no cover + f"event_id {event['event_id']!r} seen but absent from WAL") + line = _map(event) + line["event_id"] = event["event_id"] + line["timestamp"] = event["timestamp"] + prev = self._read_lines() + line["seq"] = len(prev) + line["prev_hash"] = prev[-1]["hash"] if prev else "0" * 16 + line["hash"] = fnv1a(_canonical({k: v for k, v in line.items() if k != "hash"})) + self.wal_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.wal_path, "a", encoding="utf-8") as f: + f.write(json.dumps(line, sort_keys=True) + "\n") + self._seen.add(event["event_id"]) + self._fold(line, self._state) + return line + + @staticmethod + def _fold(line: Dict[str, Any], st: Dict[str, Any]) -> None: + st["lines"] += 1 + op, args = line["op"], line.get("args", {}) + if op == "BIND" and line["cell"] == "vessel/identity": + st["identity"] = args + elif op == "BIND" and line["cell"] == "vessel/skills": + if args.get("skill") not in st["skills"]: + st["skills"].append(args.get("skill")) + elif op == "LINK": + st["worklog"] += 1 + elif op == "EFFECT": + k = args.get("kind") + if k == "task": + st["tasks_completed" if args.get("success") else "tasks_failed"] += 1 + elif k == "promotion": + st["promotions"].append((args.get("from_stage"), args.get("to_stage"))) + elif k == "fence": + if args.get("fence_name") not in st["fences"]: + st["fences"].append(args.get("fence_name")) + elif op == "TICK": + st["last_tick"] = line.get("timestamp") + elif op == "FORGET": + st["archived"] = bool(args.get("archived")) + + def state(self) -> Dict[str, Any]: + return self._state + + def replay(self) -> Dict[str, Any]: + st = self._empty_state() + for line in self._read_lines(): + self._fold(line, st) + return st + + def verify(self) -> Dict[str, Any]: + """Replay the WAL from disk and diff against expectations. + + Checks: (a) per-line hash recomputation, (b) chain linkage, (c) seq + continuity, (d) replay equivalence with the live reducer state. + """ + divergences: List[str] = [] + lines = self._read_lines() + for i, line in enumerate(lines): + want = fnv1a(_canonical({k: v for k, v in line.items() if k != "hash"})) + if line.get("hash") != want: + divergences.append(f"line {i}: hash mismatch (tampered)") + if i == 0: + if line.get("prev_hash") != "0" * 16: + divergences.append("line 0: genesis prev_hash must be 16 zeros") + else: + if line.get("prev_hash") != lines[i - 1].get("hash"): + divergences.append(f"line {i}: chain link broken (truncation or reorder)") + if line.get("seq") != i: + divergences.append(f"line {i}: seq gap (expected {i}, got {line.get('seq')})") + replayed = self.replay() + if replayed != self._state: + divergences.append("replay state diverges from live reducer state") + return {"ok": not divergences, "divergences": divergences} diff --git a/src/git_agent/schemas/event.schema.json b/src/git_agent/schemas/event.schema.json new file mode 100644 index 0000000..8d1b0cb --- /dev/null +++ b/src/git_agent/schemas/event.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "git-agent vessel lifecycle event", + "description": "Schema for events ingested by git_agent.quilt_emit. Lives with the producer (git-agent), per fleet doctrine: schemas belong with the producer, not the linter.", + "type": "object", + "required": ["event_id", "type", "timestamp"], + "additionalProperties": true, + "definitions": { + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}" + } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "session_start" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "name", "designation", "version"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "designation": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "domains": { "type": "array", "items": { "type": "string" } } + } + } + }, + { + "if": { "properties": { "type": { "const": "worklog" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "action", "target", "summary", "outcome"], + "properties": { + "action": { "type": "string", "minLength": 1 }, + "target": { "type": "string", "minLength": 1 }, + "summary": { "type": "string", "minLength": 1 }, + "outcome": { "type": "string", "enum": ["success", "failure", "partial"] } + } + } + }, + { + "if": { "properties": { "type": { "const": "task_completion" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "success"], + "properties": { "success": { "type": "boolean" } } + } + }, + { + "if": { "properties": { "type": { "const": "promotion" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "from_stage", "to_stage"], + "properties": { + "from_stage": { "type": "string", "minLength": 1 }, + "to_stage": { "type": "string", "minLength": 1 } + } + } + }, + { + "if": { "properties": { "type": { "const": "fence" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "fence_name"], + "properties": { "fence_name": { "type": "string", "minLength": 1 } } + } + }, + { + "if": { "properties": { "type": { "const": "skill" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "skill"], + "properties": { "skill": { "type": "string", "minLength": 1 } } + } + }, + { + "if": { "properties": { "type": { "const": "snapshot" } } }, + "then": { + "required": ["event_id", "type", "timestamp", "stage"], + "properties": { "stage": { "type": "string", "minLength": 1 } } + } + }, + { + "if": { "properties": { "type": { "const": "session_end" } } }, + "then": { + "required": ["event_id", "type", "timestamp"], + "properties": { "archived": { "type": "boolean" } } + } + }, + { + "if": { "properties": { "type": { "const": "heartbeat" } } }, + "then": { "required": ["event_id", "type", "timestamp"] } + } + ], + "properties": { + "event_id": { "type": "string", "minLength": 1 }, + "type": { + "type": "string", + "enum": [ + "session_start", "worklog", "task_completion", "promotion", + "fence", "skill", "snapshot", "heartbeat", "session_end" + ] + }, + "timestamp": { "$ref": "#/definitions/timestamp" } + } +} diff --git a/tests/_capture_fixtures.py b/tests/_capture_fixtures.py new file mode 100644 index 0000000..df04741 --- /dev/null +++ b/tests/_capture_fixtures.py @@ -0,0 +1,88 @@ +"""One-shot fixture capture: emit the REAL lifecycle events a git-agent vessel +produces, save them as test fixtures. Run: python3 _capture_fixtures.py """ +import datetime +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from git_agent.vessel import VesselManager, WorklogEntry, check_promotion, GrowthStage + + +def ts(): + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def main(out_dir: str): + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + n = [0] + + def eid(): + n[0] += 1 + return f"evt-{n[0]:04d}-captured" + + with tempfile.TemporaryDirectory() as td: + vm = VesselManager(local_path=Path(td)) + ident = vm.state.identity + + fx = {} + fx["session_start"] = { + "event_id": eid(), "type": "session_start", "timestamp": ts(), + "name": ident.name, "designation": ident.designation, + "version": ident.version, "domains": [d.value for d in ident.domains], + } + + entry = WorklogEntry( + timestamp=ts(), action="branched", target="SuperInstance/git-agent", + summary="Created feature branch quilt-emitter", outcome="success") + vm.add_worklog_entry(entry) + fx["worklog"] = { + "event_id": eid(), "type": "worklog", "timestamp": entry.timestamp, + "action": entry.action, "target": entry.target, + "summary": entry.summary, "outcome": entry.outcome, + } + + before = vm.state.career.total_tasks_completed + vm.record_task_completion(success=True) + promoted = vm.state.career.current_stage != GrowthStage.INITIATE or vm.state.career.total_tasks_completed > before + fx["task_completion"] = { + "event_id": eid(), "type": "task_completion", "timestamp": ts(), + "success": True, + } + + # a promotion event as recorded in the worklog by record_task_completion + promo_entry = vm.state.worklog[-1] if vm.state.worklog[-1].action == "promoted" else None + fx["promotion"] = { + "event_id": eid(), "type": "promotion", "timestamp": promo_entry.timestamp if promo_entry else ts(), + "from_stage": GrowthStage.INITIATE.value, + "to_stage": (promo_entry.target if promo_entry else GrowthStage.APPRENTICE.value), + } + + vm.complete_fence("first-pr") + fx["fence"] = {"event_id": eid(), "type": "fence", "timestamp": ts(), "fence_name": "first-pr"} + + vm.acquire_skill("code-review") + fx["skill"] = {"event_id": eid(), "type": "skill", "timestamp": ts(), "skill": "code-review"} + + fx["snapshot"] = { + "event_id": eid(), "type": "snapshot", "timestamp": ts(), + "stage": vm.state.career.current_stage.value, + "total_tasks_completed": vm.state.career.total_tasks_completed, + "total_tasks_failed": vm.state.career.total_tasks_failed, + "worklog_len": len(vm.state.worklog), + } + + fx["heartbeat"] = {"event_id": eid(), "type": "heartbeat", "timestamp": ts()} + + fx["session_end"] = {"event_id": eid(), "type": "session_end", "timestamp": ts(), "archived": True} + + for name, payload in fx.items(): + (out / f"{name}.json").write_text(json.dumps(payload, indent=2) + "\n") + print(f"captured {len(fx)} fixtures -> {out}") + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "fixtures") diff --git a/tests/fixtures/fence.json b/tests/fixtures/fence.json new file mode 100644 index 0000000..b7e878b --- /dev/null +++ b/tests/fixtures/fence.json @@ -0,0 +1,6 @@ +{ + "event_id": "evt-0005-captured", + "type": "fence", + "timestamp": "2026-09-26T00:22:43.521442+00:00", + "fence_name": "first-pr" +} diff --git a/tests/fixtures/heartbeat.json b/tests/fixtures/heartbeat.json new file mode 100644 index 0000000..2a118c2 --- /dev/null +++ b/tests/fixtures/heartbeat.json @@ -0,0 +1,5 @@ +{ + "event_id": "evt-0008-captured", + "type": "heartbeat", + "timestamp": "2026-09-26T00:22:43.521478+00:00" +} diff --git a/tests/fixtures/promotion.json b/tests/fixtures/promotion.json new file mode 100644 index 0000000..8b58892 --- /dev/null +++ b/tests/fixtures/promotion.json @@ -0,0 +1,7 @@ +{ + "event_id": "evt-0004-captured", + "type": "promotion", + "timestamp": "2026-09-26T00:22:43.521427+00:00", + "from_stage": "initiate", + "to_stage": "apprentice" +} diff --git a/tests/fixtures/session_end.json b/tests/fixtures/session_end.json new file mode 100644 index 0000000..9851ab9 --- /dev/null +++ b/tests/fixtures/session_end.json @@ -0,0 +1,6 @@ +{ + "event_id": "evt-0009-captured", + "type": "session_end", + "timestamp": "2026-09-26T00:22:43.521483+00:00", + "archived": true +} diff --git a/tests/fixtures/session_start.json b/tests/fixtures/session_start.json new file mode 100644 index 0000000..13a1bda --- /dev/null +++ b/tests/fixtures/session_start.json @@ -0,0 +1,11 @@ +{ + "event_id": "evt-0001-captured", + "type": "session_start", + "timestamp": "2026-09-26T00:22:43.521309+00:00", + "name": "Super Z", + "designation": "Git-Native Agent", + "version": "0.1.0", + "domains": [ + "general" + ] +} diff --git a/tests/fixtures/skill.json b/tests/fixtures/skill.json new file mode 100644 index 0000000..42a3220 --- /dev/null +++ b/tests/fixtures/skill.json @@ -0,0 +1,6 @@ +{ + "event_id": "evt-0006-captured", + "type": "skill", + "timestamp": "2026-09-26T00:22:43.521463+00:00", + "skill": "code-review" +} diff --git a/tests/fixtures/snapshot.json b/tests/fixtures/snapshot.json new file mode 100644 index 0000000..0b5b51d --- /dev/null +++ b/tests/fixtures/snapshot.json @@ -0,0 +1,9 @@ +{ + "event_id": "evt-0007-captured", + "type": "snapshot", + "timestamp": "2026-09-26T00:22:43.521470+00:00", + "stage": "initiate", + "total_tasks_completed": 1, + "total_tasks_failed": 0, + "worklog_len": 1 +} diff --git a/tests/fixtures/task_completion.json b/tests/fixtures/task_completion.json new file mode 100644 index 0000000..cafc2d0 --- /dev/null +++ b/tests/fixtures/task_completion.json @@ -0,0 +1,6 @@ +{ + "event_id": "evt-0003-captured", + "type": "task_completion", + "timestamp": "2026-09-26T00:22:43.521420+00:00", + "success": true +} diff --git a/tests/fixtures/worklog.json b/tests/fixtures/worklog.json new file mode 100644 index 0000000..bc00039 --- /dev/null +++ b/tests/fixtures/worklog.json @@ -0,0 +1,9 @@ +{ + "event_id": "evt-0002-captured", + "type": "worklog", + "timestamp": "2026-09-26T00:22:43.521373+00:00", + "action": "branched", + "target": "SuperInstance/git-agent", + "summary": "Created feature branch quilt-emitter", + "outcome": "success" +} diff --git a/tests/test_quilt_emit.py b/tests/test_quilt_emit.py new file mode 100644 index 0000000..3a31276 --- /dev/null +++ b/tests/test_quilt_emit.py @@ -0,0 +1,195 @@ +""" +Behavioral tests for the Vessel-Quilt emitter (git_agent.quilt_emit). + +Fixtures in tests/fixtures/*.json are REAL lifecycle event payloads captured +from a live VesselManager by tests/_capture_fixtures.py — not invented shapes. + +FAIL-first by construction: these tests were written before quilt_emit existed +and failed at import; the module was then built until green. +""" + +from __future__ import annotations + +import copy +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from git_agent.quilt_emit import ( + QuiltEmitter, + QuiltValidationError, + fnv1a, + SCHEMA_PATH, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load(name: str) -> dict: + return json.loads((FIXTURES / f"{name}.json").read_text()) + + +@pytest.fixture() +def emitter(tmp_path): + return QuiltEmitter(wal_path=tmp_path / "quilt.jsonl") + + +# ── opcode mapping (one fleet opcode per vessel event type) ────────────── + +@pytest.mark.parametrize("fixture,opcode", [ + ("session_start", "BIND"), + ("worklog", "LINK"), + ("task_completion", "EFFECT"), + ("promotion", "EFFECT"), + ("fence", "EFFECT"), + ("skill", "BIND"), + ("snapshot", "VIEW"), + ("heartbeat", "TICK"), + ("session_end", "FORGET"), +]) +def test_each_event_type_maps_to_its_quilt_opcode(emitter, fixture, opcode): + line = emitter.ingest(load(fixture)) + assert line["op"] == opcode, f"{fixture} should map to {opcode}, got {line['op']}" + + +def test_worklog_link_carries_relation_args(emitter): + line = emitter.ingest(load("worklog")) + assert line["cell"] == "vessel/worklog" + a = line["args"] + assert a["action"] == "branched" + assert a["target"] == "SuperInstance/git-agent" + assert a["outcome"] == "success" + + +def test_promotion_effect_names_both_stages(emitter): + line = emitter.ingest(load("promotion")) + assert line["args"]["from_stage"] == "initiate" + assert line["args"]["to_stage"] == "apprentice" + assert line["args"]["kind"] == "promotion" + + +def test_session_start_binds_identity(emitter): + line = emitter.ingest(load("session_start")) + assert line["cell"] == "vessel/identity" + assert line["args"]["name"] == "Super Z" + assert line["args"]["designation"] == "Git-Native Agent" + + +def test_task_completion_effect_records_pass_fail(emitter): + ok = emitter.ingest(load("task_completion")) + assert ok["args"]["success"] is True + bad_event = load("task_completion") + bad_event.update(event_id="evt-fail-1", success=False) + bad = emitter.ingest(bad_event) + assert bad["args"]["success"] is False + + +# ── schema validation at the boundary ──────────────────────────────────── + +def test_missing_required_field_rejected_with_precise_error(emitter): + ev = load("worklog") + del ev["outcome"] + with pytest.raises(QuiltValidationError) as exc: + emitter.ingest(ev) + assert "outcome" in str(exc.value) + + +def test_bad_outcome_enum_rejected(emitter): + ev = load("worklog") + ev["outcome"] = "exploded" + with pytest.raises(QuiltValidationError): + emitter.ingest(ev) + + +def test_unknown_event_type_rejected(emitter): + ev = load("heartbeat") + ev["type"] = "plasma" + with pytest.raises(QuiltValidationError): + emitter.ingest(ev) + + +def test_rejection_does_not_break_the_ingest_loop(emitter): + bad = load("worklog") + del bad["target"] + with pytest.raises(QuiltValidationError): + emitter.ingest(bad) + good = emitter.ingest(load("worklog")) + assert good["op"] == "LINK" # loop survived the bad event + + +def test_schema_file_lives_with_the_producer(): + assert SCHEMA_PATH.exists() + schema = json.loads(SCHEMA_PATH.read_text()) + assert "worklog" in json.dumps(schema) + + +# ── WAL integrity: fnv1a hash chain ────────────────────────────────────── + +def test_hash_chain_links_every_line(emitter): + for name in ("session_start", "worklog", "task_completion"): + emitter.ingest(load(name)) + wal = emitter.wal() + assert len(wal) == 3 + assert wal[0]["prev_hash"] == "0" * 16 + for prev, cur in zip(wal, wal[1:]): + assert cur["prev_hash"] == prev["hash"] + + +def test_verify_ok_on_untampered_wal(emitter): + for name in ("session_start", "worklog", "snapshot"): + emitter.ingest(load(name)) + result = emitter.verify() + assert result["ok"] is True + assert result["divergences"] == [] + + +def test_verify_detects_tampering(emitter, tmp_path): + for name in ("session_start", "worklog"): + emitter.ingest(load(name)) + lines = (tmp_path / "quilt.jsonl").read_text().splitlines() + tampered = json.loads(lines[0]) + tampered["args"]["name"] = "Mallory" + lines[0] = json.dumps(tampered) + (tmp_path / "quilt.jsonl").write_text("\n".join(lines) + "\n") + result = emitter.verify() + assert result["ok"] is False + assert result["divergences"] + + +def test_verify_detects_truncation(emitter, tmp_path): + for name in ("session_start", "worklog", "snapshot"): + emitter.ingest(load(name)) + lines = (tmp_path / "quilt.jsonl").read_text().splitlines() + (tmp_path / "quilt.jsonl").write_text("\n".join(lines[:2]) + "\n") + result = emitter.verify() + assert result["ok"] is False + + +# ── replay equivalence: WAL → reducer state ────────────────────────────── + +def test_replay_reproduces_ingested_state(emitter): + for name in ("session_start", "worklog", "worklog", "promotion", "snapshot"): + ev = load(name) + ev["event_id"] = f"evt-replay-{name}-{emitter.wal().__len__()}" + emitter.ingest(ev) + live = emitter.state() + replayed = emitter.replay() + assert replayed == live + + +def test_at_least_once_duplicate_ingest_is_idempotent(emitter): + ev = load("worklog") + emitter.ingest(ev) + emitter.ingest(copy.deepcopy(ev)) # same event_id, delivered twice + emitter.ingest(copy.deepcopy(ev)) + assert len(emitter.wal()) == 1 # single WAL entry for one event_id + + +def test_fnv1a_is_stable_and_seedless(): + assert fnv1a("hello") == fnv1a("hello") + assert fnv1a("hello") != fnv1a("hellp") + assert len(fnv1a("anything")) == 16 # 64-bit → 16 hex chars