activegraph 1.0.0rc2__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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,846 @@
1
+ """Object, Relation, Graph + projector.
2
+
3
+ CONTRACT #2 (strict): Graph state is materialized from the event log.
4
+ `emit(event)` is the only mutator. Convenience methods (`add_object`,
5
+ `add_relation`, `patch_object`, `propose_patch`, `apply_patch`, etc.) all
6
+ build an Event and call emit.
7
+
8
+ CONTRACT v0.5 #15: the projector is `apply_event(graph, event)`, a
9
+ module-level function — the ONLY thing that mutates graph state. It is
10
+ called from two paths:
11
+ - `Graph.emit` for live events (also persists + notifies listeners)
12
+ - `Graph._replay_event` for replay (silent: no persist, no notify)
13
+ Two callers, one code path.
14
+
15
+ CONTRACT #5 (provenance): every object/relation/patch carries a provenance
16
+ dict written here, never by the behavior. Behaviors pass `data`; we strip
17
+ any `provenance` key they sneak in.
18
+
19
+ CONTRACT #4 (versioning): every object has a monotonic `version` int that
20
+ bumps on every patch.applied. Patches record `expected_version`.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import copy
26
+ from dataclasses import dataclass
27
+ from typing import Any, Callable, Optional
28
+
29
+ from activegraph.core.clock import Clock
30
+ from activegraph.core.event import Event
31
+ from activegraph.core.ids import IDGen
32
+ from activegraph.core.patch import Patch
33
+
34
+
35
+ # ---------- handles ----------
36
+
37
+
38
+ @dataclass
39
+ class Object:
40
+ id: str
41
+ type: str
42
+ data: dict[str, Any]
43
+ version: int
44
+ provenance: dict[str, Any]
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "id": self.id,
49
+ "type": self.type,
50
+ "data": copy.deepcopy(self.data),
51
+ "version": self.version,
52
+ "provenance": copy.deepcopy(self.provenance),
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class Relation:
58
+ id: str
59
+ source: str
60
+ target: str
61
+ type: str
62
+ data: dict[str, Any]
63
+ provenance: dict[str, Any]
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "id": self.id,
68
+ "source": self.source,
69
+ "target": self.target,
70
+ "type": self.type,
71
+ "data": copy.deepcopy(self.data),
72
+ "provenance": copy.deepcopy(self.provenance),
73
+ }
74
+
75
+
76
+ # ---------- graph ----------
77
+
78
+
79
+ def _strip_provenance(data: dict[str, Any]) -> dict[str, Any]:
80
+ """Behaviors do not get to set provenance (CONTRACT #5)."""
81
+ if not isinstance(data, dict):
82
+ return data
83
+ return {k: v for k, v in data.items() if k != "provenance"}
84
+
85
+
86
+ def _diff(old: dict[str, Any], updates: dict[str, Any]) -> dict[str, dict[str, Any]]:
87
+ """Return per-field {old, new} for fields that actually change."""
88
+ out: dict[str, dict[str, Any]] = {}
89
+ for k, new_v in updates.items():
90
+ old_v = old.get(k, _MISSING)
91
+ if old_v != new_v:
92
+ out[k] = {"old": None if old_v is _MISSING else old_v, "new": new_v}
93
+ return out
94
+
95
+
96
+ _MISSING = object()
97
+
98
+
99
+ class Graph:
100
+ """Event-sourced graph. The log is truth; objects/relations are projection."""
101
+
102
+ def __init__(
103
+ self,
104
+ ids: Optional[IDGen] = None,
105
+ clock: Optional[Clock] = None,
106
+ run_id: Optional[str] = None,
107
+ ) -> None:
108
+ self.ids = ids or IDGen()
109
+ self.clock = clock or Clock()
110
+ # CONTRACT v0.5 #6: every graph has a run_id.
111
+ self.run_id: str = run_id or self.ids.run()
112
+
113
+ # projected state — touched ONLY by apply_event (CONTRACT v0.5 #15)
114
+ self._objects: dict[str, Object] = {}
115
+ self._relations: dict[str, Relation] = {}
116
+ self._patches: dict[str, Patch] = {}
117
+
118
+ # the log
119
+ self._events: list[Event] = []
120
+
121
+ # listeners (the runtime queue subscribes here)
122
+ self._listeners: list[Callable[[Event], None]] = []
123
+
124
+ # CONTRACT v0.5 #14: track which events were replayed (not live).
125
+ # The trace printer renders them with a [replay.event] prefix.
126
+ self._replayed_ids: set[str] = set()
127
+
128
+ # Optional persistence sink (attached by Runtime when persist_to=...).
129
+ self._store = None # type: ignore[assignment]
130
+
131
+ # v0.9: optional schema validators attached by `runtime.load_pack`.
132
+ # `_pack_object_validator(type, data) -> validated_data` is called
133
+ # from `add_object`. `_pack_relation_validator(type, src_type,
134
+ # tgt_type) -> None` is called from `add_relation`. When None
135
+ # (default), behavior is unchanged from v0.8.
136
+ self._pack_object_validator = None
137
+ self._pack_relation_validator = None
138
+
139
+ # ---------- read API ----------
140
+
141
+ @property
142
+ def events(self) -> list[Event]:
143
+ return list(self._events)
144
+
145
+ @property
146
+ def replayed_ids(self) -> frozenset[str]:
147
+ return frozenset(self._replayed_ids)
148
+
149
+ def get_object(self, id_: str) -> Optional[Object]:
150
+ return self._objects.get(id_)
151
+
152
+ def get_relation(self, id_: str) -> Optional[Relation]:
153
+ return self._relations.get(id_)
154
+
155
+ def get_patch(self, id_: str) -> Optional[Patch]:
156
+ return self._patches.get(id_)
157
+
158
+ def all_objects(self) -> list[Object]:
159
+ return list(self._objects.values())
160
+
161
+ def all_relations(self) -> list[Relation]:
162
+ return list(self._relations.values())
163
+
164
+ def get_relations(
165
+ self,
166
+ object_id: Optional[str] = None,
167
+ type: Optional[str] = None,
168
+ direction: str = "both",
169
+ ) -> list[Relation]:
170
+ out: list[Relation] = []
171
+ for r in self._relations.values():
172
+ if type is not None and r.type != type:
173
+ continue
174
+ if object_id is not None:
175
+ if direction == "outgoing" and r.source != object_id:
176
+ continue
177
+ if direction == "incoming" and r.target != object_id:
178
+ continue
179
+ if direction == "both" and object_id not in (r.source, r.target):
180
+ continue
181
+ out.append(r)
182
+ return out
183
+
184
+ def neighborhood(self, object_id: str, depth: int = 1) -> tuple[list[Object], list[Relation]]:
185
+ if object_id not in self._objects:
186
+ return ([], [])
187
+ seen_objs = {object_id}
188
+ frontier = {object_id}
189
+ seen_rels: set[str] = set()
190
+ for _ in range(depth):
191
+ next_frontier: set[str] = set()
192
+ for r in self._relations.values():
193
+ if r.source in frontier or r.target in frontier:
194
+ seen_rels.add(r.id)
195
+ if r.source not in seen_objs:
196
+ next_frontier.add(r.source)
197
+ if r.target not in seen_objs:
198
+ next_frontier.add(r.target)
199
+ seen_objs |= next_frontier
200
+ frontier = next_frontier
201
+ if not frontier:
202
+ break
203
+ return (
204
+ [self._objects[i] for i in seen_objs if i in self._objects],
205
+ [self._relations[i] for i in seen_rels if i in self._relations],
206
+ )
207
+
208
+ def query(
209
+ self,
210
+ object_type: Optional[str] = None,
211
+ where: Optional[dict[str, Any]] = None,
212
+ ) -> list[Object]:
213
+ out: list[Object] = []
214
+ for o in self._objects.values():
215
+ if object_type is not None and o.type != object_type:
216
+ continue
217
+ if where and not _eval_where_on_object(where, o):
218
+ continue
219
+ out.append(o)
220
+ return out
221
+
222
+ def has_object_of_type(self, type_: str) -> bool:
223
+ return any(o.type == type_ for o in self._objects.values())
224
+
225
+ # ---------- listener API (runtime hooks here) ----------
226
+
227
+ def add_listener(self, fn: Callable[[Event], None]) -> None:
228
+ self._listeners.append(fn)
229
+
230
+ # ---------- store attachment (Runtime sets this) ----------
231
+
232
+ def attach_store(self, store) -> None:
233
+ """Wire an EventStore as the durability sink. Idempotent on the same
234
+ store. Calling with a *different* store after events exist is an error
235
+ — events would be persisted in two places and you'd lose history.
236
+ """
237
+ if self._store is store:
238
+ return
239
+ if self._store is not None:
240
+ from activegraph.runtime.config_errors import IncompatibleRuntimeState
241
+ raise IncompatibleRuntimeState(
242
+ "graph already has a store attached",
243
+ what_failed=(
244
+ "Graph.attach_store() was called, but this graph already "
245
+ "has a store. Stores attach at most once per graph "
246
+ "lifetime."
247
+ ),
248
+ why=(
249
+ "A graph's store is the durability target for every "
250
+ "event it emits. Re-attaching a second store would "
251
+ "either (a) split the event log across two stores, "
252
+ "with subsequent events going to the new one and "
253
+ "earlier events stuck in the old, or (b) try to copy "
254
+ "the old log to the new store, which is a migration, "
255
+ "not an attach. The framework refuses re-attach so "
256
+ "neither failure mode is reachable silently."
257
+ ),
258
+ how_to_fix=(
259
+ "If you want to copy the graph's run to a new store, "
260
+ "use the migration primitive on the existing store's "
261
+ "URL after the run completes:\n"
262
+ " activegraph migrate --from <old-url> --to <new-url>\n"
263
+ "\n"
264
+ "If the graph is fresh and the existing store is a "
265
+ "placeholder (e.g., from a test fixture), construct a "
266
+ "new Graph rather than re-attaching."
267
+ ),
268
+ )
269
+ self._store = store
270
+
271
+ @property
272
+ def store(self):
273
+ return self._store
274
+
275
+ # ---------- the only mutator (live path) ----------
276
+
277
+ def emit(self, event: Event) -> Event:
278
+ """Append to log, project, persist (if attached), notify. CONTRACT #2."""
279
+ # Fail-fast serialization check at emit time so bad payloads never
280
+ # land in the in-memory log either (CONTRACT v0.5 #4).
281
+ if self._store is not None:
282
+ from activegraph.store.serde import validate_event
283
+
284
+ validate_event(event)
285
+ self._events.append(event)
286
+ apply_event(self, event)
287
+ if self._store is not None:
288
+ self._store.append(event)
289
+ for listener in self._listeners:
290
+ listener(event)
291
+ return event
292
+
293
+ # ---------- the only mutator (replay path) ----------
294
+
295
+ def _replay_event(self, event: Event) -> None:
296
+ """Apply a recorded event WITHOUT persisting or firing listeners.
297
+
298
+ Used only by `Runtime.load` and `Runtime.fork`. CONTRACT v0.5 #14:
299
+ replay rebuilds graph state; it does NOT fire behaviors.
300
+ """
301
+ self._events.append(event)
302
+ apply_event(self, event)
303
+ self._replayed_ids.add(event.id)
304
+
305
+ # ---------- convenience builders (each builds an Event and emits) ----------
306
+
307
+ def add_object(
308
+ self,
309
+ type: str,
310
+ data: dict[str, Any],
311
+ *,
312
+ actor: str = "system",
313
+ caused_by: Optional[str] = None,
314
+ frame_id: Optional[str] = None,
315
+ evidence: Optional[list[str]] = None,
316
+ llm_request_event_id: Optional[str] = None,
317
+ tool_request_event_ids: Optional[list[str]] = None,
318
+ ) -> Object:
319
+ obj_id = self.ids.object(type)
320
+ clean = _strip_provenance(copy.deepcopy(data))
321
+ # v0.9: schema validation against loaded pack object types.
322
+ # Validator is set by runtime.load_pack and is None when no
323
+ # typed pack contributes this object type — preserving v0.8
324
+ # untyped semantics (CONTRACT v0.9 #5 / #21).
325
+ if self._pack_object_validator is not None:
326
+ clean = self._pack_object_validator(type, clean)
327
+ provenance = self._provenance(
328
+ actor,
329
+ caused_by,
330
+ frame_id,
331
+ evidence,
332
+ llm_request_event_id,
333
+ tool_request_event_ids,
334
+ )
335
+ payload = {
336
+ "object": {
337
+ "id": obj_id,
338
+ "type": type,
339
+ "data": clean,
340
+ "version": 1,
341
+ "provenance": provenance,
342
+ },
343
+ "id": obj_id,
344
+ }
345
+ event = Event(
346
+ id=self.ids.event(),
347
+ type="object.created",
348
+ payload=payload,
349
+ actor=actor,
350
+ frame_id=frame_id,
351
+ caused_by=caused_by,
352
+ timestamp=self.clock.now(),
353
+ )
354
+ self.emit(event)
355
+ return self._objects[obj_id]
356
+
357
+ def add_relation(
358
+ self,
359
+ source: str,
360
+ target: str,
361
+ type: str,
362
+ data: Optional[dict[str, Any]] = None,
363
+ *,
364
+ actor: str = "system",
365
+ caused_by: Optional[str] = None,
366
+ frame_id: Optional[str] = None,
367
+ llm_request_event_id: Optional[str] = None,
368
+ tool_request_event_ids: Optional[list[str]] = None,
369
+ ) -> Relation:
370
+ rel_id = self.ids.relation()
371
+ clean = _strip_provenance(copy.deepcopy(data or {}))
372
+ # v0.9: relation type validation (source/target type rules).
373
+ if self._pack_relation_validator is not None:
374
+ src_obj = self._objects.get(source)
375
+ tgt_obj = self._objects.get(target)
376
+ self._pack_relation_validator(
377
+ type,
378
+ src_obj.type if src_obj else None,
379
+ tgt_obj.type if tgt_obj else None,
380
+ )
381
+ provenance = self._provenance(
382
+ actor,
383
+ caused_by,
384
+ frame_id,
385
+ [],
386
+ llm_request_event_id,
387
+ tool_request_event_ids,
388
+ )
389
+ payload = {
390
+ "relation": {
391
+ "id": rel_id,
392
+ "source": source,
393
+ "target": target,
394
+ "type": type,
395
+ "data": clean,
396
+ "provenance": provenance,
397
+ },
398
+ "id": rel_id,
399
+ "source": source,
400
+ "target": target,
401
+ }
402
+ event = Event(
403
+ id=self.ids.event(),
404
+ type="relation.created",
405
+ payload=payload,
406
+ actor=actor,
407
+ frame_id=frame_id,
408
+ caused_by=caused_by,
409
+ timestamp=self.clock.now(),
410
+ )
411
+ self.emit(event)
412
+ return self._relations[rel_id]
413
+
414
+ def remove_relation(
415
+ self,
416
+ relation_id: str,
417
+ *,
418
+ actor: str = "system",
419
+ caused_by: Optional[str] = None,
420
+ frame_id: Optional[str] = None,
421
+ ) -> None:
422
+ if relation_id not in self._relations:
423
+ return
424
+ event = Event(
425
+ id=self.ids.event(),
426
+ type="relation.removed",
427
+ payload={"id": relation_id},
428
+ actor=actor,
429
+ frame_id=frame_id,
430
+ caused_by=caused_by,
431
+ timestamp=self.clock.now(),
432
+ )
433
+ self.emit(event)
434
+
435
+ def remove_object(
436
+ self,
437
+ object_id: str,
438
+ *,
439
+ actor: str = "system",
440
+ caused_by: Optional[str] = None,
441
+ frame_id: Optional[str] = None,
442
+ ) -> None:
443
+ if object_id not in self._objects:
444
+ return
445
+ event = Event(
446
+ id=self.ids.event(),
447
+ type="object.removed",
448
+ payload={"id": object_id},
449
+ actor=actor,
450
+ frame_id=frame_id,
451
+ caused_by=caused_by,
452
+ timestamp=self.clock.now(),
453
+ )
454
+ self.emit(event)
455
+
456
+ def patch_object(
457
+ self,
458
+ target: str,
459
+ updates: dict[str, Any],
460
+ *,
461
+ actor: str = "system",
462
+ caused_by: Optional[str] = None,
463
+ frame_id: Optional[str] = None,
464
+ rationale: Optional[str] = None,
465
+ evidence: Optional[list[str]] = None,
466
+ llm_request_event_id: Optional[str] = None,
467
+ tool_request_event_ids: Optional[list[str]] = None,
468
+ ) -> Patch:
469
+ """Auto-apply shortcut: build patch, version-check, emit applied/rejected."""
470
+ obj = self._objects.get(target)
471
+ if obj is None:
472
+ raise KeyError(f"unknown object: {target}")
473
+ clean = _strip_provenance(copy.deepcopy(updates))
474
+ patch = Patch(
475
+ id=self.ids.patch(),
476
+ target=target,
477
+ op="update",
478
+ value=clean,
479
+ expected_version=obj.version,
480
+ proposed_by=actor,
481
+ rationale=rationale,
482
+ evidence=list(evidence or []),
483
+ status="applied",
484
+ provenance=self._provenance(
485
+ actor,
486
+ caused_by,
487
+ frame_id,
488
+ evidence,
489
+ llm_request_event_id,
490
+ tool_request_event_ids,
491
+ ),
492
+ )
493
+ diff = _diff(obj.data, clean)
494
+ event = Event(
495
+ id=self.ids.event(),
496
+ type="patch.applied",
497
+ payload={
498
+ "patch": patch.to_dict(),
499
+ "target": target,
500
+ "diff": diff,
501
+ },
502
+ actor=actor,
503
+ frame_id=frame_id,
504
+ caused_by=caused_by,
505
+ timestamp=self.clock.now(),
506
+ )
507
+ self.emit(event)
508
+ return self._patches[patch.id]
509
+
510
+ def propose_patch(
511
+ self,
512
+ target: str,
513
+ op: str,
514
+ value: dict[str, Any],
515
+ *,
516
+ proposed_by: str,
517
+ rationale: Optional[str] = None,
518
+ evidence: Optional[list[str]] = None,
519
+ caused_by: Optional[str] = None,
520
+ frame_id: Optional[str] = None,
521
+ llm_request_event_id: Optional[str] = None,
522
+ tool_request_event_ids: Optional[list[str]] = None,
523
+ ) -> Patch:
524
+ # Strip "object:" / "relation:" prefix if present (README sugar).
525
+ normalized = target.split(":", 1)[1] if ":" in target else target
526
+ obj = self._objects.get(normalized)
527
+ expected_version = obj.version if obj else 0
528
+ clean = _strip_provenance(copy.deepcopy(value))
529
+ patch = Patch(
530
+ id=self.ids.patch(),
531
+ target=normalized,
532
+ op=op,
533
+ value=clean,
534
+ expected_version=expected_version,
535
+ proposed_by=proposed_by,
536
+ rationale=rationale,
537
+ evidence=list(evidence or []),
538
+ status="proposed",
539
+ provenance=self._provenance(
540
+ proposed_by,
541
+ caused_by,
542
+ frame_id,
543
+ evidence,
544
+ llm_request_event_id,
545
+ tool_request_event_ids,
546
+ ),
547
+ )
548
+ event = Event(
549
+ id=self.ids.event(),
550
+ type="patch.proposed",
551
+ payload={"patch": patch.to_dict()},
552
+ actor=proposed_by,
553
+ frame_id=frame_id,
554
+ caused_by=caused_by,
555
+ timestamp=self.clock.now(),
556
+ )
557
+ self.emit(event)
558
+ return self._patches[patch.id]
559
+
560
+ def apply_patch(
561
+ self,
562
+ patch_id: str,
563
+ *,
564
+ approved_by: str = "system",
565
+ caused_by: Optional[str] = None,
566
+ frame_id: Optional[str] = None,
567
+ ) -> Event:
568
+ patch = self._patches.get(patch_id)
569
+ if patch is None:
570
+ raise KeyError(f"unknown patch: {patch_id}")
571
+ if patch.status != "proposed":
572
+ from activegraph.runtime.exec_errors import InvalidPatchLifecycleState
573
+ raise InvalidPatchLifecycleState(
574
+ patch_id=patch_id, current_status=patch.status,
575
+ )
576
+ target_obj = self._objects.get(patch.target)
577
+ current_version = target_obj.version if target_obj else 0
578
+ if current_version != patch.expected_version:
579
+ return self._reject(
580
+ patch_id,
581
+ reason=f"version mismatch: expected {patch.expected_version}, got {current_version}",
582
+ actor=approved_by,
583
+ caused_by=caused_by,
584
+ frame_id=frame_id,
585
+ )
586
+ diff = _diff(target_obj.data if target_obj else {}, patch.value)
587
+ event = Event(
588
+ id=self.ids.event(),
589
+ type="patch.applied",
590
+ payload={
591
+ "patch": {**patch.to_dict(), "status": "applied"},
592
+ "target": patch.target,
593
+ "diff": diff,
594
+ "approved_by": approved_by,
595
+ },
596
+ actor=approved_by,
597
+ frame_id=frame_id,
598
+ caused_by=caused_by,
599
+ timestamp=self.clock.now(),
600
+ )
601
+ return self.emit(event)
602
+
603
+ def reject_patch(
604
+ self,
605
+ patch_id: str,
606
+ reason: str,
607
+ *,
608
+ actor: str = "system",
609
+ caused_by: Optional[str] = None,
610
+ frame_id: Optional[str] = None,
611
+ ) -> Event:
612
+ return self._reject(patch_id, reason, actor=actor, caused_by=caused_by, frame_id=frame_id)
613
+
614
+ def _reject(
615
+ self,
616
+ patch_id: str,
617
+ reason: str,
618
+ *,
619
+ actor: str,
620
+ caused_by: Optional[str],
621
+ frame_id: Optional[str],
622
+ ) -> Event:
623
+ patch = self._patches[patch_id]
624
+ current = self._objects.get(patch.target)
625
+ event = Event(
626
+ id=self.ids.event(),
627
+ type="patch.rejected",
628
+ payload={
629
+ "patch_id": patch_id,
630
+ "target": patch.target,
631
+ "reason": reason,
632
+ "current_version": current.version if current else 0,
633
+ },
634
+ actor=actor,
635
+ frame_id=frame_id,
636
+ caused_by=caused_by,
637
+ timestamp=self.clock.now(),
638
+ )
639
+ return self.emit(event)
640
+
641
+ # ---------- provenance helper (CONTRACT v0.5 #13: includes run_id) ----------
642
+
643
+ def _provenance(
644
+ self,
645
+ actor: str,
646
+ caused_by: Optional[str],
647
+ frame_id: Optional[str],
648
+ evidence: Optional[list[str]],
649
+ llm_request_event_id: Optional[str] = None,
650
+ tool_request_event_ids: Optional[list[str]] = None,
651
+ ) -> dict[str, Any]:
652
+ p: dict[str, Any] = {
653
+ "created_by": actor,
654
+ "caused_by_event": caused_by,
655
+ "frame_id": frame_id,
656
+ "timestamp": self.clock.now(),
657
+ "evidence": list(evidence or []),
658
+ "run_id": self.run_id,
659
+ }
660
+ # CONTRACT v0.6 #15: objects/relations/patches created inside an
661
+ # @llm_behavior handler carry the llm.requested event id so causal
662
+ # chain walks can cross the LLM boundary.
663
+ if llm_request_event_id is not None:
664
+ p["llm_request_event_id"] = llm_request_event_id
665
+ # CONTRACT v0.7 #19: when the LLM behavior's turn loop invoked
666
+ # tools, all contributing tool.requested event ids are stamped
667
+ # here so causal-chain walks can enumerate every tool call.
668
+ if tool_request_event_ids:
669
+ p["tool_request_event_ids"] = list(tool_request_event_ids)
670
+ return p
671
+
672
+
673
+ # ---------- the projector — module-level, single mutation code path ----------
674
+
675
+
676
+ def apply_event(graph: Graph, event: Event) -> None:
677
+ """Project an event onto the graph's in-memory state.
678
+
679
+ The ONLY function that mutates `_objects` / `_relations` / `_patches`.
680
+ Called from `Graph.emit` (live) and `Graph._replay_event` (replay).
681
+ Pure projection — no I/O, no listener calls, no event log mutation.
682
+ """
683
+ t = event.type
684
+ p = event.payload
685
+
686
+ if t == "object.created":
687
+ o = p["object"]
688
+ graph._objects[o["id"]] = Object(
689
+ id=o["id"],
690
+ type=o["type"],
691
+ data=copy.deepcopy(o["data"]),
692
+ version=o["version"],
693
+ provenance=copy.deepcopy(o["provenance"]),
694
+ )
695
+
696
+ elif t == "object.removed":
697
+ graph._objects.pop(p["id"], None)
698
+ # cascade: drop relations touching it
699
+ for rid in [
700
+ r.id for r in graph._relations.values() if p["id"] in (r.source, r.target)
701
+ ]:
702
+ graph._relations.pop(rid, None)
703
+
704
+ elif t == "relation.created":
705
+ r = p["relation"]
706
+ graph._relations[r["id"]] = Relation(
707
+ id=r["id"],
708
+ source=r["source"],
709
+ target=r["target"],
710
+ type=r["type"],
711
+ data=copy.deepcopy(r["data"]),
712
+ provenance=copy.deepcopy(r["provenance"]),
713
+ )
714
+
715
+ elif t == "relation.removed":
716
+ graph._relations.pop(p["id"], None)
717
+
718
+ elif t == "patch.proposed":
719
+ patch_dict = p["patch"]
720
+ graph._patches[patch_dict["id"]] = _patch_from_dict(patch_dict)
721
+
722
+ elif t == "patch.applied":
723
+ patch_dict = p["patch"]
724
+ patch = _patch_from_dict({**patch_dict, "status": "applied"})
725
+ graph._patches[patch.id] = patch
726
+ obj = graph._objects.get(patch.target)
727
+ if obj is not None:
728
+ if patch.op == "update":
729
+ obj.data.update(patch.value)
730
+ elif patch.op == "replace":
731
+ obj.data = copy.deepcopy(patch.value)
732
+ obj.version += 1
733
+
734
+ elif t == "patch.rejected":
735
+ existing = graph._patches.get(p["patch_id"])
736
+ if existing is not None:
737
+ existing.status = "rejected"
738
+ existing.rejection_reason = p["reason"]
739
+
740
+
741
+ def _patch_from_dict(d: dict[str, Any]) -> Patch:
742
+ return Patch(
743
+ id=d["id"],
744
+ target=d["target"],
745
+ op=d["op"],
746
+ value=copy.deepcopy(d["value"]),
747
+ expected_version=d["expected_version"],
748
+ proposed_by=d["proposed_by"],
749
+ rationale=d.get("rationale"),
750
+ evidence=list(d.get("evidence", [])),
751
+ status=d.get("status", "proposed"),
752
+ rejection_reason=d.get("rejection_reason"),
753
+ provenance=copy.deepcopy(d.get("provenance", {})),
754
+ )
755
+
756
+
757
+ # ---------- where evaluator (used by query and matchers) ----------
758
+
759
+ _OPS = {
760
+ ">": lambda a, b: a is not None and a > b,
761
+ "<": lambda a, b: a is not None and a < b,
762
+ ">=": lambda a, b: a is not None and a >= b,
763
+ "<=": lambda a, b: a is not None and a <= b,
764
+ "==": lambda a, b: a == b,
765
+ "!=": lambda a, b: a != b,
766
+ "in": lambda a, b: a in b,
767
+ "not in": lambda a, b: a not in b,
768
+ }
769
+
770
+
771
+ def _resolve_path(root: Any, path: list[str]) -> Any:
772
+ cur = root
773
+ for p in path:
774
+ if isinstance(cur, dict):
775
+ cur = cur.get(p)
776
+ elif hasattr(cur, p):
777
+ cur = getattr(cur, p)
778
+ else:
779
+ return None
780
+ if cur is None:
781
+ return None
782
+ return cur
783
+
784
+
785
+ def evaluate_where(where: dict[str, Any], root: Any) -> bool:
786
+ """Evaluate a where dict against a root (event payload, object, etc.).
787
+
788
+ Keys are dotted paths. Values are either literals (equality) or
789
+ `{"op": value}` dicts for comparisons.
790
+ """
791
+ for key, expected in where.items():
792
+ actual = _resolve_path(root, key.split("."))
793
+ if isinstance(expected, dict):
794
+ for op, value in expected.items():
795
+ fn = _OPS.get(op)
796
+ if fn is None:
797
+ from activegraph.errors import internal_bug_fields
798
+ from activegraph.runtime.exec_errors import (
799
+ InternalEvaluatorError,
800
+ )
801
+ _fields = internal_bug_fields(
802
+ summary=f"unknown where operator: {op!r}",
803
+ what_happened=(
804
+ f"The view-filter evaluator in graph.py received "
805
+ f"comparison operator {op!r}, but the operator "
806
+ f"table (_OPS in this module) has no handler for it."
807
+ ),
808
+ why_invariant=(
809
+ "The operator table is the source of truth for "
810
+ "which comparison operators view filters accept. "
811
+ "An unknown operator means either the filter was "
812
+ "constructed by code that bypassed the parser, or "
813
+ "the operator table drifted from the parser. "
814
+ "Either way, evaluating the filter would silently "
815
+ "produce wrong results — refuse instead."
816
+ ),
817
+ location="activegraph/core/graph.py:evaluate_where",
818
+ extra_context={"operator": op},
819
+ )
820
+ raise InternalEvaluatorError(
821
+ _fields["summary"],
822
+ what_failed=_fields["what_failed"],
823
+ why=_fields["why"],
824
+ how_to_fix=_fields["how_to_fix"],
825
+ context=_fields["context"],
826
+ )
827
+ if not fn(actual, value):
828
+ return False
829
+ else:
830
+ if actual != expected:
831
+ return False
832
+ return True
833
+
834
+
835
+ def _eval_where_on_object(where: dict[str, Any], obj: Object) -> bool:
836
+ """Where on a bare Object — keys are paths under data unless they start with one of the object fields."""
837
+ root = {
838
+ "id": obj.id,
839
+ "type": obj.type,
840
+ "data": obj.data,
841
+ "version": obj.version,
842
+ "provenance": obj.provenance,
843
+ # also expose data fields at top level for convenience
844
+ **obj.data,
845
+ }
846
+ return evaluate_where(where, root)