# Active Graph > An event-sourced reactive graph runtime for long-running, auditable, agentic systems. Active Graph is an event-sourced reactive graph runtime for long-running, auditable, agentic systems. Behaviors react to events, mutate a shared graph, and emit more events; the event log is the source of truth, so every run is replayable, forkable, and diff-able from its log. Install with `pip install activegraph` and run `activegraph quickstart` for the bundled fixture-backed demo. # Quickstart # Quickstart Ten minutes from install to a working custom behavior. By the end of this tutorial you'll have run the framework, written your own code in it, saved a run, inspected it from the outside, and used the fork-and-diff primitive that's specific to Active Graph and uncommon in other agent frameworks. The seven steps build on each other; do them in order. If you finish in less than ten minutes, the tutorial isn't broken — you read faster than the average. If you finish in more than fifteen, something is rough; file an issue at [GitHub](https://github.com/yoheinakajima/activegraph/issues) with where you got stuck. ## 1. Install ```bash pip install activegraph activegraph --version ``` The bare install includes the runtime, the SQLite store, and the bundled Diligence pack. No API key needed for this tutorial. ## 2. Run the bundled demo ```bash activegraph quickstart ``` This runs the bundled Diligence pack against recorded fixtures — three companies, three diligence memos, no network, about twenty seconds. The run is byte-deterministic; every machine produces the same output, which is why the snapshot test for this command works. You'll see four sections of output: a header naming the pack and companies, a long trace, one of the produced memos rendered in full, and two prose sections ("what just happened" and "try next"). Don't worry about reading every trace line yet — step 3 is where we'll look at the trace. The key beat: that memo came from nothing but a fixture-backed demo, in seconds, with the same output on your machine as on mine. The framework's pitch is "auditable agentic systems"; the deterministic demo is that pitch made tangible. ## 3. Read the trace Scroll back up to the trace block in the output. The lines starting with `[goal.created]`, `[behavior.started]`, `[llm.requested]`, `[object.created]`, and so on are **events** — the framework's append-only record of everything that happened. Active Graph models the world as a graph of objects connected by typed edges; events are how the graph changes over time. A few specific lines to find: - `[pack.loaded]` near the top — when the Diligence pack registered its behaviors, tools, and prompt templates. - `[goal.created] user: "Diligence: Northwind Robotics"` — when the runtime received its first goal. - A `[behavior.started]` for `diligence.company_planner` followed immediately by `[object.created] company#1` — the planner behavior fired in response to the goal, and produced a `company` object on the graph. - `[llm.requested]` and `[llm.responded]` pairs — every LLM call was served by the bundled fixture provider (`RecordedDiligenceProvider`), so no network requests fired. The trace shows `cost=$0.00X latency=0.Xs` on each `llm.responded` line; in a production run against a real provider those numbers would be real costs and real latencies. - `[runtime.idle] queue empty, budget remaining` at the end of each goal's events — the runtime finished all the work it could do and stopped. Two layers worth distinguishing now so the vocabulary lands cleanly later: the **provider** is what produces LLM responses (here, the fixture provider; in production, an `AnthropicProvider`). The runtime's **replay cache** is a separate layer that records `llm.responded` events and serves them back when a run replays under strict-replay mode or when `Runtime.fork(at_event=...)` is called in-process — that's where you'll see `cache_hit=true` in the trace. See [`concepts/replay`](https://docs.activegraph.ai/concepts/replay/index.md) and [`concepts/forking`](https://docs.activegraph.ai/concepts/forking/index.md) for the deep dive. That trace is the framework's audit trail. The same artifact you just read for fun is what you'd read while debugging a production incident. Most agent frameworks don't have this kind of trace, and that's one of the things that makes Active Graph different. We'll come back to events in more detail in [`concepts/events`](https://docs.activegraph.ai/concepts/events/index.md). For now: the trace is the truth; everything else is a projection of it. ## 4. Write a custom behavior ```bash activegraph quickstart --interactive ``` This walks you through writing your first behavior. A **behavior** is the framework's unit of reactive code — a Python function decorated with `@behavior` that subscribes to events and produces more events (new objects, new relations, custom events). The interactive command scaffolds a starter behavior at `./activegraph_quickstart/my_first_behavior.py` and prompts you to edit it. The TODO in the scaffold is a small problem: flag any claim that mentions revenue growth above 25%. You can parse the text with a regex; the framework supplies the integration with the graph. Open the file in your editor, replace the TODO with the parsing logic, and save. The full file is short — fewer than twenty lines when you're done. Don't worry about getting the regex perfect. The goal of this step is to feel the shape of writing a behavior: a decorator declaring when it fires, a function body that reads from the event and writes to the graph. We'll go deeper on behaviors in [`concepts/behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md). ## 5. Run your behavior Back in the terminal, type `continue` at the prompt. The framework loads your file fresh, runs the Diligence pack against one company, and reports how many times your behavior fired. Scroll the trace and find your behavior's lines: ```text [behavior.started] growth_flagger (matched object.created: claim#NN) [event.emitted] growth.flagged claim_id=claim#NN growth=28 [behavior.completed] growth_flagger ``` That's your code, running in the same runtime as the Diligence pack, firing on the same events, producing events that downstream behaviors could subscribe to. Your behavior is a first-class citizen of the graph — there's no separate "user behavior" path. Iterate as much as you want — edit the file, type `continue`, edit again. When you're done, type `quit`. Your file persists at `./activegraph_quickstart/my_first_behavior.py`; keep it, modify it, or delete the directory. ## 6. Save and inspect The fixture-backed run from step 2 saved itself to `/tmp/activegraph_quickstart/quickstart_demo_run.db`. Try inspecting it: ```bash activegraph inspect sqlite:////tmp/activegraph_quickstart/quickstart_demo_run.db ``` You'll see a summary: run id, state, budget snapshot, registered behaviors, the tail of recent events. The same data the trace showed, but as a query surface — you read it from outside the run, which means you can read it after the run finishes, after the process exits, after a restart. Active Graph runs persist. Try a focused query: ```bash activegraph inspect sqlite:////tmp/activegraph_quickstart/quickstart_demo_run.db \ --event evt_006 ``` That prints the full payload of one event. The event id is what an error message would name if something went wrong; `--event` is how you'd start investigating. `activegraph inspect --help` shows the full surface. The [CLI reference](https://docs.activegraph.ai/reference/cli/index.md) is the canonical doc; the [debugging cookbook](https://docs.activegraph.ai/cookbook/debugging/index.md) walks through diagnostic workflows that build on these primitives. ## 7. Fork and diff The closer. Forking is the framework's most differentiated capability — most agent frameworks can't do this. A fork is a new run that shares the parent's event log up to a chosen point, then diverges from there. Combined with a diff against the parent, fork answers the question "what would have happened if I'd configured this differently?" The full fork-with-override workflow uses both a Python snippet and the `activegraph diff` CLI command. Drop this into a file (`fork_and_diff.py`) and run it: ```python import sqlite3 from activegraph import Runtime from activegraph.packs.diligence import DiligenceSettings, pack as diligence_pack from activegraph.packs.diligence.fixtures import ( RecordedDiligenceProvider, THREE_COMPANIES, ) from activegraph.store import open_store from activegraph.store.sqlite import SQLiteEventStore DB_PATH = "/tmp/activegraph_quickstart/quickstart_demo_run.db" PARENT_URL = f"sqlite:///{DB_PATH}" PARENT_RUN = "quickstart_demo_run" FORK_RUN = "quickstart_cautious" # Tutorial-only: remove any prior fork so this snippet is re-runnable. # Real workflows handle fork-id collisions intentionally — pick a # unique FORK_RUN per experiment instead of deleting the prior one. with sqlite3.connect(DB_PATH) as _conn: deleted = _conn.execute( "DELETE FROM events WHERE run_id = ?", (FORK_RUN,) ).rowcount _conn.execute("DELETE FROM runs WHERE run_id = ?", (FORK_RUN,)) if deleted: print(f"Removed previous fork ({deleted} events) to re-run cleanly.") # Pick the goal event for the first company as the fork point. parent_store = open_store(PARENT_URL, run_id=PARENT_RUN) fork_at = next( e.id for e in parent_store.iter_events() if e.type == "goal.created" ) # Copy the parent's events up to the fork point into a new run. SQLiteEventStore.fork_run( DB_PATH, parent_run_id=PARENT_RUN, new_run_id=FORK_RUN, at_event_id=fork_at, label="cautious", created_at="2026-01-01T00:00:00Z", ) # Load the fork. The provider matches the parent run's # RecordedDiligenceProvider, so cached LLM responses from the parent # replay byte-identically and no network or API key is needed. fork_rt = Runtime.load( PARENT_URL, run_id=FORK_RUN, llm_provider=RecordedDiligenceProvider(companies=THREE_COMPANIES), ) fork_rt.load_pack( diligence_pack, settings=DiligenceSettings( llm_model="claude-sonnet-4-5", confidence_threshold_for_review=0.9, ), ) fork_rt.run_until_idle() fork_rt.save_state() print(f"forked: {FORK_RUN} (parent: {PARENT_RUN})") print(f"next: activegraph diff {PARENT_URL} \\") print(f" --run-a {PARENT_RUN} --run-b {FORK_RUN}") ``` The snippet does the fork half of fork-and-diff; the diff half is a CLI command that reads the same SQLite file from outside. The snippet's final two `print` lines spell out the exact command — copy it from the terminal output, or use the block below: ```bash activegraph diff sqlite:////tmp/activegraph_quickstart/quickstart_demo_run.db \ --run-a quickstart_demo_run \ --run-b quickstart_cautious ``` You'll see five counts (shared events, parent-only events, fork-only events, divergent objects, divergent relations) and a list of objects that exist in both runs with different state. On the bundled fixtures the diff produces 61 divergent objects and 49 divergent relations — the threshold change fans out further than you'd guess. The first divergent object is where the threshold change started producing different work. What you just did: ran the same starting state through a different decision, and got a structural comparison of the results. Hypothesis testing on an agentic system, without losing the parent run. This is what fork-and-diff means in this framework. The fork-and-diff workflow will collapse into a single `activegraph fork --set` CLI command in v1.1 ([CONTRACT v1.1 #1](https://github.com/yoheinakajima/activegraph/blob/main/CONTRACT.md#v11-1-cli-flags-specd-but-not-implemented)); the Python form above is the v1.0 canonical recipe. For the conceptual deep-dive on forks (shared lineage, cache replay, the strict-vs-permissive replay distinction), read [`concepts/forking`](https://docs.activegraph.ai/concepts/forking/index.md). ## What to read next You've now run the framework, written your own behavior, persisted a run, queried it from outside, and forked it. That's the loop; everything else is depth on one of these primitives. In rough order of usefulness from here: - [`concepts/graph`](https://docs.activegraph.ai/concepts/graph/index.md) and [`concepts/behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the mental model. Read both in one sitting; together they take about fifteen minutes. - [`cookbook/common-patterns`](https://docs.activegraph.ai/cookbook/common-patterns/index.md) — recurring idioms with copy-pasteable code. Eight patterns, most of which apply to the kind of agentic systems you'd build on this framework. - [`cookbook/debugging`](https://docs.activegraph.ai/cookbook/debugging/index.md) — the operator- facing diagnostic walkthrough. Useful when something goes wrong; useful before something goes wrong because it teaches you how the framework's audit trail actually works. - [`reference/cli`](https://docs.activegraph.ai/reference/cli/index.md) — the full CLI surface. - [`concepts/failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — the framework's stance on what counts as a recoverable failure. Short, load-bearing, worth reading once. - [Authoring packs](https://docs.activegraph.ai/quickstart/guides/writing-behaviors.md) and [Writing LLM behaviors](https://docs.activegraph.ai/quickstart/guides/writing-llm-behaviors.md) — for when you're ready to build something larger than a single behavior. If you're back here on Monday, you found what you were looking for. # Concepts # Graph The graph is the world state of an Active Graph run. Objects sit on it as typed nodes; relations connect them as typed edges. Behaviors react to changes in the graph by emitting more changes. Goals are the inputs operators push in from the outside. The graph isn't a control-flow structure. It models what the system **knows about**, not what the system **does next**. That's the load-bearing distinction between Active Graph and workflow-graph frameworks (LangGraph, the various DAG runners) — the nodes here are facts and entities, not steps. Steps are behaviors, and behaviors live alongside the graph, not inside it. ## Graph as projection of the event log The graph is the projection of an append-only event log. Every mutation — `add_object`, `patch_object`, `add_relation`, every behavior fire — emits an event. The event lands in the store, and the graph in memory is updated. `Runtime.load(url, run_id=...)` reconstructs the graph by replaying the events; nothing else is persisted. This is the framework's most foundational invariant. Other concepts pages link here for it: - [`events`](https://docs.activegraph.ai/concepts/events/index.md) documents the event types that drive the projection. - [`replay`](https://docs.activegraph.ai/concepts/replay/index.md) is the operation that uses the projection property to reconstruct state. - [`forking`](https://docs.activegraph.ai/concepts/forking/index.md) creates a new run by copying a prefix of the event log; the forked graph is the projection of that prefix. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) is why the framework refuses to silently produce events that don't represent real work — the projection would lie. You can read the graph state at any time: ```python graph.all_objects() # every object graph.objects(type="claim") # filtered by type graph.relations(source=claim_id) # outgoing edges graph.relations(target=claim_id) # incoming edges graph.relations(type="depends_on") # by edge type graph.get_object(object_id) # by id ``` `graph.relations(source=, target=, type=)` is the canonical filter API on `Graph`; all three kwargs compose by AND, and calling with no kwargs returns every relation. `graph.get_relations(object_id=, type=, direction=)` is an alias preserved for backward compatibility; new code should use `graph.relations(...)`. But you can't mutate it except through events. There's no `graph.objects["x"] = ...` setter; every mutation goes through a method that emits an event. ## Objects Objects are typed entities. The type is a string declared by the pack that owns the object type (`@pack(object_types=[...])`) or freeform if no pack declares it. The data is a dict of JSON-encodable values: ```python claim = graph.add_object("claim", { "text": "Q3 revenue grew 28% YoY.", "confidence": 0.85, }) ``` Object ids are framework-generated (`IDGen`), monotonic per run, and unique per run. The pack format can declare a schema (Pydantic model) for the object type; if so, the data is validated at `add_object` — see [`pack-schema-violation`](https://docs.activegraph.ai/reference/errors/pack-schema-violation/index.md). ## Relations Relations are typed edges between objects. The type is a string, the endpoints are object ids, and optional data is a dict on the edge itself: ```python graph.add_relation(claim.id, evidence.id, "supports", {"strength": 0.9}) ``` Relations have ids too (also framework-generated). A relation type can carry a behavior — see [`relations`](https://docs.activegraph.ai/concepts/relations/index.md) for the distinction between passive, rule, and agentic relations. ## Goals Goals are the inputs operators push in from outside. A goal isn't an object on the graph; it's an event of type `goal.created` that behaviors subscribed to it react to: ```python rt.run_goal("Diligence: Northwind Robotics") ``` Behaviors on `goal.created` fire first; their output (objects, relations, more events) triggers other behaviors, and the runtime loop continues until the queue is empty. ## What's NOT on the graph - **Control flow.** The runtime's behavior dispatch is not modeled as graph nodes. The graph models the work product (objects, relations); behaviors are the framework's reactive code. - **Configuration.** Pack settings, budget limits, the runtime's store URL — none of these are graph state. They're constructor arguments. - **The event log itself.** The graph is a *projection* of the log; the log itself lives in the store. Read it via `graph.events` (in-memory) or `activegraph inspect` (operator-side). ## What's related - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — the append-only history that drives the graph projection. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the reactive code that mutates the graph in response to events. - [`relations`](https://docs.activegraph.ai/concepts/relations/index.md) — the typed-edge primitive and its optional behaviors. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — why the framework refuses to silently bypass the event log. # Type system Active Graph has three layers of types: **event types** (framework- defined), **object types** (developer-defined), and **relation types** (developer-defined). One layer is the fixed vocabulary the framework speaks; the other two are the domain vocabulary the developer chooses. A maintainer reading this page for the first time will most likely arrive looking for the answer to one question: *are there framework base types I need to know about?* The answer is no — for objects and relations. The framework ships zero base object types and zero base relation types. The Diligence pack's `claim / evidence / question / memo / …` ontology is an example, not a base. This page covers the three layers, how they compose, the patch-lifecycle states (the fourth small framework-defined vocabulary), and design guidance for the developer-defined layers. ## The framework-defined layer: event types Every event has a `type` — a string discriminator that says what happened. The framework emits a fixed set of dotted-namespace event types; user code may emit additional types via `graph.emit` (any string is valid, the dot-namespaced convention is recommended). The fixed set is the framework's vocabulary; the things you can build on top of it. The complete set of framework-emitted event types: ### Lifecycle - **`goal.created`** — an operator pushed a goal into the run (`rt.run_goal("…")`). Behaviors subscribed to `goal.created` fire first; the runtime loop continues from their output. - **`runtime.idle`** — the runtime queue is empty and there is budget remaining; the loop is paused, ready to resume on the next emit. - **`runtime.budget_exhausted`** — the per-run budget (LLM tokens, wall-clock seconds, behavior fires) was hit; the loop stops with this event as its terminal record. ### Graph mutations - **`object.created`** — `graph.add_object(...)` succeeded. Payload carries the full object — id, type, data, version, provenance. - **`object.removed`** — `graph.remove_object(...)` succeeded. - **`relation.created`** — `graph.add_relation(...)` succeeded. Payload carries source, target, type, data, provenance. - **`relation.removed`** — `graph.remove_relation(...)` succeeded. ### Behavior dispatch - **`behavior.scheduled`** — the runtime queued a behavior for dispatch. One per matching subscription on the triggering event. - **`behavior.started`** — the behavior body began executing. - **`behavior.completed`** — the body returned without raising. - **`behavior.failed`** — the body raised; the runtime caught the exception and emitted this event. Payload carries the reason code and structured failure context. See [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) for the events-not-exceptions principle and [`reference/errors`](https://docs.activegraph.ai/reference/errors/index.md) for the closed reason-code taxonomy. - **`relation_behavior.started`** — a `@relation_behavior` body began; sibling of `behavior.started`, carries the bound relation. ### Patterns - **`pattern.matched`** — a Cypher-subset pattern subscription matched. Emitted before `behavior.started` for the matched bindings; carries the binding map. See [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md). ### LLM and tools - **`llm.requested`** / **`llm.responded`** — every LLM call appears as a request/response pair in the event log. Payload carries prompt content hash, model name, recorded-fixture key (in fixture-replay runs), and the response body. - **`tool.requested`** / **`tool.responded`** — every tool call, same shape. Payload carries the tool name, input, output, and cache-hit status. ### Patches - **`patch.proposed`** — `graph.propose_patch(...)` or `ctx.propose_object(...)` recorded a proposal. Carries the target id, observed version, intended diff, proposer identity. - **`patch.applied`** — the proposal succeeded (or `graph.patch_object(...)` shortcut ran). Carries the resulting object version and the computed diff. - **`patch.rejected`** — the proposal was refused (version conflict, policy refusal, or explicit `reject_patch`). Carries the rejection reason. ### Approvals - **`approval.proposed`** — a policy-gated mutation produced a pending approval. Carries the approval id and the object/patch it gates. - **`approval.granted`** — `runtime.approve(approval_id)` resolved a pending approval; the gated mutation lands. ### Pack lifecycle - **`pack.loaded`** — `runtime.load_pack(...)` succeeded. Carries the pack name, version, object/relation types, behaviors, tools, policies, prompt content hashes, and the canonical settings dump. The pack-load order participates in the replay contract — a loaded run replays the same `pack.loaded` event at the same point in the log. This list is the framework's stable vocabulary. The cookbook, trace formatter, replay engine, observability metrics, and CLI inspect command all key off these types. Custom event types from user code live alongside them and follow the same shape; the framework treats unknown types as opaque payload carriers. ## The developer-defined layer: object types `graph.add_object(type, data)` accepts **any string** as the type. There is no central enum, no required `register_object_type(...)` call, no schema-definition step. The framework's stance is that an object type is whatever string identifies the role an object plays in your domain. ```python graph.add_object("claim", {"text": "Q3 revenue grew 28% YoY.", "confidence": 0.85}) graph.add_object("memo", {"company_id": "obj_007", "summary": "…"}) graph.add_object("topic", {"name": "battery thermal runaway"}) ``` These three calls each produce an `object.created` event with the given type string. The framework does not check the type against anything. The data dict is JSON-encodability-validated and otherwise opaque. If you come from a typed-schema background (databases, Pydantic, GraphQL, Protobuf), expect a schema-definition step and don't find it — there isn't one. This is intentional. The framework's abstraction surface is *events and reactions*, not *entity-relationship diagrams*. Schemas are useful when you have them; the optional-validation path below shows how to add one. ### Optional: pack-level schema validation A pack can declare an object type with a Pydantic schema, and the runtime validates `add_object(type, data)` against the schema **after the pack is loaded**: ```python from pydantic import BaseModel, Field from activegraph.packs import ObjectType, Pack class Claim(BaseModel): text: str confidence: float = Field(ge=0.0, le=1.0) pack = Pack( name="my_pack", version="0.1.0", object_types=[ObjectType(name="claim", schema=Claim, description="…")], # … ) ``` After `runtime.load_pack(pack)`, `add_object("claim", data)` validates `data` against `Claim`; a mismatch raises [`pack-schema-violation`](https://docs.activegraph.ai/reference/errors/pack-schema-violation/index.md). **Validation is post-load and not retroactive** — objects of type `claim` created before the pack loaded stay as-is; objects of types no loaded pack contributes pass through unchanged. This preserves the no-pack default: any string works, any data shape works, you opt into a schema by loading a pack that declares one. See [`authoring-packs`](https://docs.activegraph.ai/guides/authoring-packs/#4-object-types-and-relation-types) for the full pack-side mechanics. ### Why the type lives on the data, not in a central schema Validation, when you want it, happens at the **binding moment** — where a behavior consumes an object type, it can declare what fields it expects. A behavior that fires on `object.created` filtered to `type="claim"` and reads `event.payload["object"]["data"]["text"]` is the de facto consumer-side schema: if the field isn't there, the behavior raises and the runtime emits `behavior.failed`. The framework's stance is that this consumer-side discipline carries the weight a central schema would, with the upside that domain ontologies can evolve without a migration step. ## The developer-defined layer: relation types Same model. `graph.add_relation(source, target, type)` accepts any string. No central registry. A pack may declare endpoint-type rules — "`supports` connects `evidence` to `claim`" — and the runtime enforces them after the pack loads: ```python from activegraph.packs import RelationType RelationType( name="supports", source_types=("evidence",), target_types=("claim",), description="Evidence supports a claim.", ) ``` Without a pack-declared rule, any source/target/type combination is allowed. Pack-declared rules raise [`pack-schema-violation`](https://docs.activegraph.ai/reference/errors/pack-schema-violation/index.md) on a forbidden endpoint pair. A relation type can also carry behavior — `@relation_behavior` attaches a rule or LLM body to a type so the type itself owns coordination logic between its endpoints. The relation kind (passive / rule / agentic) is a property of the *type*, not of any individual relation instance. See [`relations`](https://docs.activegraph.ai/concepts/relations/index.md) for that distinction. ## How the three layers compose The framework's vocabulary is the event types; the domain vocabulary is the object and relation types the developer chooses. The two interlock through behaviors: 1. An operator pushes a `goal.created` event (framework type). 1. A behavior subscribed to `goal.created` runs and creates an object — `graph.add_object("topic", …)` (developer type). 1. The runtime emits an `object.created` event (framework type) carrying the new `topic` object (developer type) in its payload. 1. Behaviors subscribed to `object.created` filtered to `type="topic"` fire — perhaps emitting `tool.requested` (framework type) for a web search, perhaps creating `query` objects (developer type). 1. The cycle continues — every developer-typed mutation produces a framework-typed event; every framework-typed event can trigger more developer-typed mutations. The discipline: the framework speaks a small fixed vocabulary about *what happened*; the developer speaks a domain vocabulary about *what kind of thing it happened to*. ## Patch lifecycle states The fourth small framework-defined vocabulary: a patch's `status` field. Three values, defined on `core/patch.py`: - **`proposed`** — the patch was recorded as a `patch.proposed` event but has not yet been applied or rejected. - **`applied`** — the patch reached its terminal "applied" state via `graph.apply_patch(patch_id)` (or the `patch_object` auto-apply shortcut). Emits `patch.applied`. - **`rejected`** — the patch reached its terminal "rejected" state via `graph.reject_patch(patch_id, reason)` or via the optimistic-concurrency version check at apply time. Emits `patch.rejected`. `proposed` is the only non-terminal state. Re-applying or re-rejecting a terminal patch raises [`invalid-patch-lifecycle-state`](https://docs.activegraph.ai/reference/errors/invalid-patch-lifecycle-state/index.md). See [`patches`](https://docs.activegraph.ai/concepts/patches/index.md) for the canonical lifecycle prose; this list exists here so the type-system page enumerates every framework-defined vocabulary in one place. ## Designing an ontology Because object and relation types are developer-defined, **the ontology is part of the system you're building**. Three rules that survive scrutiny across the v0.7 / v0.9 / external-research- agent ontologies the framework has been built and tested against: **Object types are nouns describing roles in the domain, not data bags.** `claim`, `evidence`, `question`, `risk` each name a role something plays in a diligence workflow; a behavior that fires on `object.created` type-filtered to one of them knows what kind of thing it's reacting to. A generic `record` or `entity` type that holds arbitrary data is a smell — the type discriminator has collapsed and behaviors lose the ability to subscribe selectively. The external user-test on a deep-research agent surfaced this explicitly: a first pass used `data` as the type for everything, and behaviors had to inspect payload shape to dispatch. The second pass split into `topic / query / fact / report`, and behaviors became one-liners on `on=["object.created"], where=lambda e: e.payload["object"]["type"] == "topic"`. **Relation types are verbs or predicates describing meaningful structure.** `supports`, `contradicts`, `depends_on`, `references`, `derived_from` each describe a relationship that something downstream cares about. A generic `related_to` is a smell — it collapses the type discriminator the same way a generic object type does, and pattern subscriptions on the relation type stop being useful. Verbs that read naturally in the call site (`graph.add_relation(evidence, claim, "supports")` reads as "evidence supports claim") are the heuristic. **Keep the vocabulary small.** Eight to fifteen object types covers most domains. The Diligence pack ships eight object types and six relation types and is intentionally on the small end of that range — packs that try to model everything tend to model nothing. New types earn their place when an actual behavior or query needs to distinguish them; future-proofing with speculative types pollutes the ontology without adding behavior. The discipline carries the weight that a central schema would: the *type itself* is the consumer-side contract. When a behavior fires on `type="claim"` it expects `claim` semantics; when it emits a `supports` relation it commits to `supports` semantics. Multiple behaviors agreeing on what those names mean is the ontology, and it's encoded in the behavior bodies — not in a schema file. ## Worked example: the Diligence pack ontology The shipped Diligence pack is a concrete, well-designed type vocabulary. It is **an example ontology, not framework base types** — you would design your own for your domain. The pack is documented here so the design pattern is visible. Eight object types (`activegraph/packs/diligence/object_types.py`): | Type | Role | | --------------- | ---------------------------------------------- | | `company` | The target of a diligence run. | | `document` | A source document the researcher pulled in. | | `question` | A research question generated from the thesis. | | `claim` | A factual statement about the company. | | `evidence` | A verbatim quote supporting a claim. | | `contradiction` | A detected conflict between two claims. | | `risk` | A material risk identified during diligence. | | `memo` | The final diligence memo for a company. | Six relation types: | Type | Endpoints (source → target) | Meaning | | -------------- | -------------------------------- | --------------------------------------------- | | `addresses` | `claim` → `question` | A claim addresses a research question. | | `supports` | `evidence` → `claim` | Evidence supports a claim. | | `contradicts` | `claim` → `claim` | Two claims are in conflict. | | `references` | `{claim, memo}` → `document` | A claim or memo references a source document. | | `derived_from` | `{claim, evidence}` → `document` | Provenance back to a source document. | | `mitigates` | `{evidence, claim}` → `risk` | Evidence or a claim mitigates a risk. | Each object type carries a Pydantic schema (validated when the pack is loaded); each relation type pins its endpoints. Together they form a small graph ontology that a small set of behaviors (claim extractor, contradiction detector, memo synthesizer) operates on. None of these types are special to the framework; load a different pack and you get a different ontology. The Diligence pack is the [reference pack](https://docs.activegraph.ai/reference/api/packs/diligence/index.md); [`authoring-packs`](https://docs.activegraph.ai/guides/authoring-packs/index.md) is the how-to for building your own. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — objects and relations as projections of the event log; the "graph as projection" principle. - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — the append-only history and how framework event types drive behavior dispatch. - [`relations`](https://docs.activegraph.ai/concepts/relations/index.md) — the three relation kinds (passive / rule / agentic) and when to attach behavior to a relation type. - [`patches`](https://docs.activegraph.ai/concepts/patches/index.md) — the patch lifecycle in full; this page only enumerates the state values. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — the `behavior.failed` reason-code taxonomy that lives on the event payload. - [`authoring-packs`](https://docs.activegraph.ai/guides/authoring-packs/index.md) — declaring object types, relation types, and their Pydantic schemas in a pack. - [Diligence pack reference](https://docs.activegraph.ai/reference/api/packs/diligence/index.md) — the worked example ontology rendered from source. # Events An event is an immutable record of something that happened in a run. Events are append-only — once an event lands in the store, nothing modifies it. The graph state is a projection of the event log (see [`graph`](https://docs.activegraph.ai/concepts/graph/index.md)); behaviors fire by subscribing to events and producing more events. The event log is the source of truth. Everything else — the graph, the trace, the audit history — is derived from it. ## The structure Every event has: - `id` — framework-generated, monotonic per run, unique per run. - `type` — a string discriminator. Framework events use a dotted namespace (`object.created`, `behavior.completed`, `runtime.idle`); user code emits custom types via `graph.emit` (any string is valid, but the dot-namespaced convention is recommended). - `payload` — a dict of JSON-encodable values. The framework enforces JSON encodability at emit time; see [`non-serializable-event-error`](https://docs.activegraph.ai/reference/errors/non-serializable-event-error/index.md). - `actor` — who or what produced the event. `"user"` for goals pushed in from outside, `"runtime"` for framework-emitted events, a behavior name for behavior-emitted events. - `caused_by` — the id of the event that triggered the behavior that produced this one. The causal chain is reconstructable by walking `caused_by` back to a root event (`goal.created`, typically). - `timestamp` — ISO 8601, set at emit time. Used for the trace display; behavior bodies must not depend on it for determinism (see [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the determinism contract). ## The framework event types Events emitted by the runtime itself fall into a small set of families: - **Lifecycle**: `goal.created`, `runtime.idle`, `runtime.budget_exhausted` — boundary events around a run. - **Object mutations**: `object.created`, `object.patched`, `object.removed` — every graph mutation lands as one of these. - **Relation mutations**: `relation.created`, `relation.removed`. - **Behavior dispatch**: `behavior.started`, `behavior.completed`, `behavior.failed`, `behavior.scheduled` — what the runtime did while running behaviors. - **Pattern matching**: `pattern.matched` — emitted before `behavior.started` when the behavior used a pattern subscription; carries the match count. - **LLM / tool**: `llm.requested`, `llm.responded`, `tool.requested`, `tool.responded` — every LLM call and every tool call appears as a request/response pair. - **Patches**: `patch.proposed`, `patch.applied`, `patch.rejected` — the patch lifecycle. - **Approvals**: `approval.proposed`, `approval.granted` — the policy-gated approval lifecycle. - **Pack lifecycle**: `pack.loaded` — emitted once per `runtime.load_pack` call, carries the pack name, version, and prompt content hashes. Custom event types from user code live alongside these and follow the same shape. Behaviors subscribe to either set with the same `on=` argument. ## Append-only and what that means Once an event is in the store, it doesn't change. No edit, no delete, no truncate (except via the explicit `truncate_after` primitive, which is operator-side, not behavior-side). This is the property that makes replay work: [`Runtime.load`](https://docs.activegraph.ai/guides/operating-in-production/index.md) reads the event log and produces the same graph state every time. Three consequences: - **There's no "current value" of an object outside its event history.** An object's data is the result of applying every `object.created` and `object.patched` event for that object id, in order. The in-memory `Object.data` dict is a cache of that computation, not an authoritative store. - **Operations that look like mutations are emissions.** `add_object` emits `object.created`; `patch_object` emits `object.patched`; `remove_object` emits `object.removed`. The graph in memory updates as a side effect of the emit. - **The audit trail is automatic.** Anything that happened in a run is in the event log. Nothing else is needed for audit — there's no separate audit-log subsystem because the event log is the audit log. ## Events vs exceptions The framework distinguishes two failure modes: exceptions for caller-actionable problems the caller can catch at the call site, events for non-fatal stops the audit trail should record and the runtime should continue past. Behavior failures, tool failures, budget exhaustion, and approval denials are events. Construction errors, lookup misses, replay divergence, and pattern syntax errors are exceptions. See [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) for the full principle and why the framework treats them differently. The principle was load-bearing across the v1.0 audit and is referenced from most other concept pages — `failure-model.md` is the canonical statement. ## Reading the event log The event log is available three ways: ```python # In-memory, current run: for event in graph.events: ... # From the store, by run id: from activegraph.store import open_store store = open_store(url, run_id) for event in store.iter_events(): ... # CLI, operator-side: # activegraph inspect --run-id --tail 50 # activegraph inspect --event ``` The trace printer (`Runtime.print_trace()`) is the human-readable projection of the event log — same data, formatted with tags and short summaries for visual scanning. The trace is informational; the events are the data. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the projection of the event log. Owns the "graph as projection" principle. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the reactive code that subscribes to events. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — the events-vs-exceptions distinction. - [`replay`](https://docs.activegraph.ai/concepts/replay/index.md) — the operation that uses the append-only property to reconstruct state. # Behaviors A behavior is the framework's unit of reactive code. It subscribes to events, runs when its subscription matches, and produces more events (new objects, new relations, patches, custom events). The runtime dispatches behaviors against events in the queue until the queue is empty. Behaviors are how a developer adds custom logic to the framework. Most code that ships with a pack is behaviors. Most code a developer writes is behaviors. A behavior is **not an agent.** It doesn't decide what to do — it reacts. The decision is the subscription rule; the work is the body. An agentic-feeling system emerges from many small behaviors firing in response to each other's outputs, not from one agent-orchestrator behavior calling everything else. ## The decorator ```python from activegraph import behavior @behavior( name="contradiction_detector", on=["object.created"], where={"object.type": "claim"}, pattern="(c:claim)-[:contradicts]->(other:claim)", view={"around": "event.payload.object.id", "depth": 1}, activate_after=1, ) def contradiction_detector(event, graph, ctx): for match in ctx.matches: ... ``` Every argument is a separate activation condition; the behavior fires when **all** of them hold: - `on=` — event types the behavior subscribes to. Most behaviors subscribe to a single type (`object.created`, `goal.created`, custom event names). Match-all is allowed with `on=["*"]` but rarely useful. - `where=` — a dict-shaped filter on the event payload. Equality on values; nested keys via dotted paths. - `pattern=` — a Cypher-subset pattern subscription. The behavior fires only when the pattern matches the graph at event time. See [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md) for the locked subset and grammar. - `view=` — a scoped view of the graph passed to the behavior body via the `ctx.view` accessor. Default is the full graph; narrow via `around=` + `depth=` to limit what the behavior reads. - `activate_after=` — schedule the behavior to fire N events after the triggering event. Integer event count only; wall-clock units are refused (see [`invalid-activate-after`](https://docs.activegraph.ai/reference/errors/invalid-activate-after/index.md)). ## The signature ```python def my_behavior(event, graph, ctx): ... ``` - `event` — the triggering event, with `id`, `type`, `payload`, `actor`, `caused_by`, `timestamp`. - `graph` — the graph as it existed at event time, scoped by the `view=` argument. - `ctx` — the runtime-bound context, with `.matches` (pattern bindings), `.view` (the scoped graph), `.propose_object` (the approval-gated add path), and a few framework-internal hooks. The body mutates the graph by calling `graph.add_object`, `graph.patch_object`, `graph.add_relation`, `graph.remove_object`, or emits arbitrary events via `graph.emit(type, payload)`. Each mutation lands as an event in the log; downstream behaviors react. ## The three behavior kinds - **Regular `@behavior`** (function or class) — the workhorse. Reacts to events, mutates the graph. Synchronous, deterministic. - **`@llm_behavior`** — wraps a function whose return value comes from an LLM call. The framework handles the prompt assembly, the provider call, the cache, the tool loop, and the schema validation; the body receives the parsed LLM output and turns it into graph mutations. See the [LLM behavior guide](https://docs.activegraph.ai/concepts/guides/writing-llm-behaviors.md). - **`@relation_behavior`** — attached to a relation type rather than an event type. Fires when an event affects an endpoint of the relation. See [`relations`](https://docs.activegraph.ai/concepts/relations/index.md). ## The determinism contract Behavior bodies must be **deterministic given their inputs**. Same event, same graph state, same view → same mutations. This is the load-bearing assumption that makes replay and forking work. Two practical consequences: - **No `random`, no `datetime.now()`, no `uuid.uuid4()` in behavior bodies.** If you need randomness or wall-clock time, get it from the event (which carries the recorded timestamp) or from the runtime's deterministic id generator (`graph.ids`). - **No I/O outside the framework's primitives.** Network calls go through `@tool` so the framework can cache and replay them. LLM calls go through `@llm_behavior` so the prompt-hash cache works. Direct `requests.get` in a behavior body breaks replay determinism in a way the framework can't recover from. The framework doesn't enforce determinism with static analysis; the discipline is on the developer. The cost of breaking it is a fork that produces a different result from its parent — see [`replay-divergence-error`](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md). ## The failure model When a behavior body raises, the runtime catches the exception and emits a `behavior.failed` event with the original exception's type, message, and (for LLM/tool errors) the structured `reason` code. The exception does NOT escape to your code — the loop continues, other behaviors keep firing, and the operator sees the failure in the trace. Code that wants to react to failures subscribes to `behavior.failed`. The retry-behavior pattern is the canonical idiom: ```python @behavior( on=["behavior.failed"], where={"reason": ["llm.network_error", "tool.timeout"]}, ) def retry_transient(event, graph, ctx): ... ``` See [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) for the events-not-exceptions principle and [`llm-behavior-error`](https://docs.activegraph.ai/reference/errors/llm-behavior-error/index.md) / [`tool-error`](https://docs.activegraph.ai/reference/errors/tool-error/index.md) for the LLM/tool failure shapes specifically. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the world state behaviors react to and mutate. - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — the append-only history behaviors subscribe to. - [`relations`](https://docs.activegraph.ai/concepts/relations/index.md) — the typed-edge primitive and `@relation_behavior`. - [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md) — the Cypher-subset pattern subscription primitive. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — what happens when a behavior body raises. - [Writing behaviors](https://docs.activegraph.ai/concepts/guides/writing-behaviors.md) — the how-to guide. # Relations A relation is a typed edge between two objects on the graph. Like objects, relations have a type (string), an id (framework-generated), optional data (dict of JSON-encodable values), and they live in the event log — created by `add_relation`, removed by `remove_relation`, each transition emitted as an event. What makes relations distinctive in this framework is that the relation type itself can carry behavior. A relation isn't just a passive edge for the graph projection to render; it can be a rule that fires when its endpoints change, or an agentic actor with its own LLM-backed reasoning. The relation type is the unit of coordination logic between its endpoints. This is the framework's most differentiated primitive. Most graph frameworks have nodes-with-behavior; relations-with-behavior is where Active Graph diverges. ## The three relation kinds Three flavors of relation type, on a spectrum of how much logic the relation itself owns: - **Passive.** No behavior attached. The relation is structural data — it exists, pattern subscriptions can match on it, behaviors on the endpoints can read it. The vast majority of relations are passive (`supports`, `contradicts`, `cites`, `depends_on`). - **Rule.** A `@relation_behavior` attached to the type. Fires deterministically when an event affects either endpoint of any relation of that type. Used for coordination logic that semantically belongs to the relationship, not to either endpoint (e.g., a `depends_on` relation that auto-blocks the dependent when the dependency changes status). - **Agentic.** A `@relation_behavior` that wraps an LLM call (same `@llm_behavior` machinery, but anchored on relation events). Used when the coordination logic needs LLM reasoning — e.g., a `contradicts` relation that drafts a contradiction-resolution memo when both endpoint claims change. The three flavors share the same event types (`relation.created`, `relation.removed`) and the same data representation. The flavor is a property of the relation *type*, not of any individual relation instance. ## The `@relation_behavior` decorator ```python from activegraph import relation_behavior @relation_behavior( name="auto_unblock", relation_type="depends_on", on=["task.completed"], ) def auto_unblock(relation, event, graph, ctx): if event.payload["task_id"] == relation.source: graph.patch_object(relation.target, {"status": "open"}) ``` The body receives the `relation` (the typed edge instance), the triggering `event`, the `graph`, and the `ctx`. The relation behavior fires once per relation that matches — if three `depends_on` edges all point at the same source and the source's `task.completed` event fires, the body runs three times, once per edge, each call with that edge as `relation`. The decorator's `relation_type=` argument narrows dispatch to one type. Other arguments (`on=`, `where=`, `pattern=`) work the same as on regular `@behavior`. See [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) for the full activation model. ## When to use a relation behavior vs a regular behavior The test: **does the coordination logic semantically belong to the relationship, not to either endpoint?** - A `depends_on` relation auto-unblocking the dependent when the dependency completes → relation behavior. The unblock logic is about the relationship, not about either task in isolation. - A `claim` getting flagged when its `confidence` drops below 0.5 → regular behavior on `object.patched`. The flag is about the claim itself; no relationship is involved. - A `contradicts` relation drafting a resolution memo when both endpoints change → agentic relation behavior. The reasoning needs both endpoints' state; it's relationship logic, not endpoint logic. When the test is ambiguous (the logic could go either way), default to regular behaviors. They're more discoverable — they show up under the endpoint's type in `inspect --behaviors`, and the coordination logic appears as a single behavior fire rather than N fires (one per matching edge). ## Pattern subscriptions and relations Pattern subscriptions match on relations naturally. The Cypher-subset syntax `(a:type1)-[r:rel_type]->(b:type2)` binds both endpoints and optionally the relation itself. See [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md) for the binding rules and when to use the `r` variable vs the bare `-[:rel_type]->` form. A behavior with a pattern subscription that mentions a relation type fires when the pattern matches — which is a different activation mechanism from `@relation_behavior` (which subscribes to events on relation endpoints rather than to graph structure). Both are valid; pick by which question you're asking: "fire when this edge plus this surrounding structure exist" (pattern) vs "fire when something happens to either end of any edge of this type" (relation behavior). ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the world state relations sit on. Relations are projections of `relation.created` / `relation.removed` events, same as objects. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the broader behavior model. `@relation_behavior` is a sibling of `@behavior` / `@llm_behavior`. - [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md) — pattern subscriptions that match on relation structure. - [Writing relation behaviors](https://docs.activegraph.ai/concepts/guides/writing-behaviors.md) — practical how-to; the decision rules for relation vs regular behavior get more attention there. # Patches A patch is a proposed mutation to the graph, recorded as an event before the mutation happens. Patches are how the framework keeps the audit trail honest about who proposed what change, what version of the target they observed, and whether the change succeeded or was refused. A direct `graph.patch_object(target, diff)` call also lands in the event log (as `object.patched`), but the patch primitive is different: it's a **two-phase** operation. The first phase records the proposal as a `patch.proposed` event, with the proposer's identity, the version of the target they observed, and the intended diff. The second phase applies (success) or rejects (refusal), emitting `patch.applied` or `patch.rejected`. The two phases let policies, behaviors, or operators sit between proposal and application. A pack's `memo_approval` policy is the canonical example — `ctx.propose_object` produces a pending approval, the operator (or an auto-approve setting) calls `runtime.approve(id)`, and the object lands only at that point. Without the two-phase shape, the policy would have to fire after the mutation, which is too late. ## The lifecycle A patch begins in `'proposed'` and ends in exactly one of two terminal states: ```text proposed ──apply──> applied | └──reject────> rejected ``` Both transitions are one-shot. A `'proposed'` patch becomes `'applied'` exactly once (via `graph.apply_patch(patch_id)`) or `'rejected'` exactly once (via `graph.reject_patch(patch_id, reason)`). Re-calling either on an already-terminal patch raises [`invalid-patch-lifecycle-state`](https://docs.activegraph.ai/reference/errors/invalid-patch-lifecycle-state/index.md) — the framework refuses to emit a duplicate `patch.applied` event because that would break the replay contract. Each transition emits an event: - `patch.proposed` — carries the proposer, target id, observed version, diff, and any provenance metadata. - `patch.applied` — carries the patch id, the resulting object version, and the mutation outcome. - `patch.rejected` — carries the patch id and the rejection reason. The events sit in the log alongside everything else. Downstream behaviors can subscribe to them, the trace renders them, and replay reconstructs the full proposal-and-decision sequence. ## Optimistic concurrency on object versions Every object carries a version that increments on each mutation. When a behavior proposes a patch, the proposal records the version of the target at proposal time. When `apply_patch` runs, it checks whether the target's current version still matches the recorded one. If not, the patch is refused with a version-conflict reason. The rule: **two behaviors that observed the same starting version can both propose patches, but only the first to apply succeeds.** The second sees the version drifted and reads its own outcome from the rejected event — usually re-reading the target and proposing a new patch against the new version. The concurrency model is optimistic by design. Locks would serialize behavior dispatch and break the parallel-firing model that pattern subscriptions and event fan-out depend on. Version checks at apply-time keep the audit trail honest without serializing. ## When to use patches vs direct mutation The test: **is this change durable or audit-critical?** - **Yes** — use a patch. Pack policies gating writes, multi-step workflows where the proposal needs to survive operator review, any state change a downstream behavior might subscribe to via `patch.proposed`. The two-phase shape is the right primitive here. - **No** — direct mutation is fine. Adding a new object, appending to a graph that has no concurrency contention, emitting an event whose payload doesn't represent durable state. `graph.add_object` and `graph.emit` cover most of this. The default is direct mutation. Patches are for the cases where the two-phase shape earns its weight — when proposal and decision are semantically distinct operations the audit trail should record separately. Most behaviors mutate directly; a small number of policy-gated behaviors propose. ## The events-not-exceptions principle applied to patches Patch rejection is a `patch.rejected` event, not an exception. A behavior that proposes a patch and finds it rejected reads the rejection from the event log; the runtime continues without interrupting. The rejection is not a failure — it's a normal outcome of the two-phase shape. The exception case is misuse of the primitive: calling `apply_patch` on a patch that's already in a terminal state. That fires [`invalid-patch-lifecycle-state`](https://docs.activegraph.ai/reference/errors/invalid-patch-lifecycle-state/index.md) because the caller can fix the bug at the call site (check status before applying) and silently no-op'ing would emit a duplicate event. See [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) for the broader principle. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the world state patches modify. Patches are projections of `patch.proposed`, `patch.applied`, and `patch.rejected` events, same as objects and relations. - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — the append-only history that records every patch transition. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — what proposes and applies patches. `ctx.propose_object` is the policy-gated path. - [`policies`](https://docs.activegraph.ai/concepts/policies/index.md) — the mechanism that gates patches through approval flows. - [`replay`](https://docs.activegraph.ai/concepts/replay/index.md) — the operation that reconstructs the full proposal-and-decision sequence from the event log. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — why patch rejection is an event but patch-lifecycle misuse is an exception. - [`invalid-patch-lifecycle-state`](https://docs.activegraph.ai/reference/errors/invalid-patch-lifecycle-state/index.md) — the exception for misuse of the patch primitive. # Views A view is a scoped read of the graph. Behaviors observe the graph through views; patches and direct mutations are how they write back. Patches and views are the read/write counterparts in the framework's behavior model — patches own the write side and the audit trail, views own the read side and the cost surface. A view is computed per-invocation. The framework doesn't cache views across behavior fires; each call to a behavior receives a freshly-scoped view of the graph as it exists at event time. That's the read-side equivalent of patches' optimistic concurrency on the write side — both primitives let parallel behaviors operate on consistent snapshots without locks. ## The scoping arguments Views are declared on the behavior decorator and accessed via `ctx.view` in the body: ```python @behavior( on=["object.created"], where={"object.type": "claim"}, view={"around": "event.payload.object.id", "depth": 2}, ) def claim_with_neighbors(event, graph, ctx): claim = ctx.view.get_object(event.payload["object"]["id"]) for related in ctx.view.objects(): ... ``` Two arguments control the scope: - `around=` — an expression evaluated against the triggering event that names the object the view centers on. Most commonly the triggering object's id (`event.payload.object.id`); also accepts a literal id, a list of ids, or `None` for a full-graph view. - `depth=` — how many relation hops to include from the `around=` center. `depth=0` includes only the center object; `depth=1` includes its direct neighbors; `depth=2` includes neighbors of neighbors. The full graph is available via `ctx.view` regardless of scope — the scope determines what the view's accessor methods return by default, not what's reachable. A scoped view's `objects()` returns objects in the scope; the underlying `graph` is still accessible if a behavior needs the unscoped read. ## Read-only contract Views never mutate. The view accessor methods (`objects()`, `relations()`, `get_object()`) return existing graph data; there's no write path through `ctx.view`. Mutations go through `graph` (or `ctx.propose_object` for the policy-gated path), not through the view. The separation is intentional. A behavior that observes through a narrow view and mutates through the full graph is the common pattern; the framework refuses to fuzz the read/write surfaces because mutations through a scoped accessor would silently miss relevant state outside the scope. ## How views compose with patterns Pattern subscriptions and view scoping serve different jobs: - The **pattern** selects which events fire the behavior. The pattern matcher reads the full graph (it has to, to evaluate the structural conditions), and produces `ctx.matches` — one entry per binding combination that satisfies the pattern. - The **view** scopes what the behavior body reads during execution. Once the behavior is firing, the view determines what `ctx.view.objects()` returns. The two can be different scopes. A pattern can match on a two-hop structural condition while the view is one-hop — the match identifies the event, the view bounds the work. Pattern bindings (`ctx.matches[i].bindings`) are object ids; the behavior can look them up against `ctx.view` when they're in scope, or against `graph` directly when the pattern matched on objects outside the view's scope. See [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md) for the pattern subscription model in detail. ## Why scope views Scoping is the framework's main cost-efficiency lever for LLM behaviors. An LLM behavior passes its view to the prompt assembler as serialized objects; the bigger the view, the bigger the prompt, the higher the cost per call. A behavior on a single claim probably doesn't need the full diligence pack's graph in its prompt — `view={"around": "event.payload.object.id", "depth": 1}` keeps the prompt focused and predictable. The cost saving compounds: 100 claim-extraction calls × 50% smaller prompt × $X/token adds up. Non-LLM behaviors benefit too, more subtly — narrow views are cheaper to construct and iterate. The cost is smaller per-call but the rule still holds: scope to what the behavior actually needs. ## What a view is not Three things views explicitly are not: - **Not a query language.** The framework deliberately doesn't have a query language beyond pattern subscriptions. Views are scoping declarations, not queries. If you find yourself wanting to filter view results by complex conditions, you're reaching for the wrong primitive — use a pattern subscription instead. - **Not a graph snapshot.** Views are computed per-invocation, not cached. A behavior firing twice on two events gets two fresh views; the framework doesn't cache or invalidate. - **Not a subscription primitive.** Patterns subscribe; views scope. The behavior fires because of `on=` / `where=` / `pattern=`; the view only determines what the body reads after it fires. The negative space matters because views are easy to over-interpret as "the LangChain retriever" or "the query DSL." They're neither. They're scoping declarations on the read side of behaviors. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the world state views observe. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — where `view=` is declared and `ctx.view` is used. - [`patterns`](https://docs.activegraph.ai/concepts/patterns/index.md) — the subscription primitive that determines when behaviors fire; views determine what they read. - [`patches`](https://docs.activegraph.ai/concepts/patches/index.md) — the write-side counterpart. Behaviors read through views and write through patches (or direct mutation). - [Writing LLM behaviors](https://docs.activegraph.ai/concepts/guides/writing-llm-behaviors.md) — practical guidance on view scoping for cost efficiency. # Frames A frame is a bounded context for behavior dispatch. Events carry a `frame_id`; behaviors can filter their subscription by `frame_id`; a run can contain multiple frames running in parallel without their behaviors crossing wires. Frames are an optional primitive. Most uses of the framework don't need them — a single-frame run is the default and covers every example in the quickstart, the diligence pack, and the cookbook patterns. **If you're not sure whether you need frames, you probably don't.** Use them when you need to scope behavior dispatch beyond a single event-type filter. ## What frames are for Three use cases where frames earn their weight: - **Multi-tenant graph state.** One runtime, many tenants. Each tenant gets a frame; behaviors filter by `frame_id` to keep tenant A's events from triggering work on tenant B's data. Without frames, the same separation would require either a per-tenant runtime (heavy) or `where=` filters on every behavior (error-prone). - **Parallel hypothesis exploration before fork is appropriate.** When the framework is reasoning about multiple hypotheses simultaneously and you want each to be a distinct context but don't yet want the fork primitive's cost (a fork is a separate run; frames are sub-contexts within one run). Useful for short-lived parallel reasoning that converges back to a single output. - **Structured conversations.** When a long-running goal has multiple distinct sub-tasks, each with its own behavior dispatch logic, frames are the bounded-context primitive for the sub-task. Each sub-task's events stay in its own frame; the sub-task's behaviors filter on the frame_id. ## How frames scope dispatch A behavior with `frame_id="..."` in its `where=` clause fires only on events from that frame: ```python @behavior( on=["object.created"], where={"frame_id": "tenant_a"}, ) def tenant_a_only(event, graph, ctx): ... ``` Without the filter, the behavior fires on events from every frame in the run. The runtime doesn't auto-scope behaviors by frame — the explicit filter is the contract. Frame ids are strings, framework- or developer-generated. Framework-generated frames use the same monotonic id pattern as events; developer-generated frames can use semantically-meaningful names (`tenant_a`, `hypothesis_left`, `sub_task_42`). ## Relationship to runs A run is the framework's top-level unit (one event log, one store binding). A frame is a sub-context within a run. - **One run, one frame** — the default. Every event in the run belongs to the same frame; behaviors don't need to filter by `frame_id`. - **One run, many frames** — the use cases above. Events from different frames coexist in the same event log; behaviors that care about isolation filter explicitly. - **Many runs, many frames** — also valid; each run's frames are independent. Common when multi-tenant systems shard tenants across multiple runs and also frame within each. Frames don't cross runs. A frame is run-scoped — moving a frame between runs would mean copying events, which is what fork and migrate are for. ## Frames vs forks Both let parallel computations proceed in isolation. The difference is durability and replay: - **Fork** is a separate run with shared event-log lineage up to the fork point. Forks are durable and replayable independently. Use fork when the parallel branches might diverge permanently or need independent persistence. - **Frames** are sub-contexts within one run. The frame's events live in the same event log as everything else; replay is the whole run. Use frames when the parallel contexts are short-lived or semantically belong together. A common pattern is to start parallel work in frames and fork only the branches that prove worth keeping. See [`forking`](https://docs.activegraph.ai/concepts/forking/index.md) for the fork primitive. ## What's related - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — where `frame_id` filters appear in `where=`. - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — events carry `frame_id`; the field is in the event structure. - [`forking`](https://docs.activegraph.ai/concepts/forking/index.md) — the durable parallel-context primitive; complements frames. - [`replay`](https://docs.activegraph.ai/concepts/replay/index.md) — frames replay as part of their run; there's no per-frame replay primitive. # Pattern subscriptions Behaviors fire on event types by default (`@behavior(on=["object.created"])`). For richer triggers — match an event against the current graph and fire only when a specific structural pattern holds — behaviors subscribe to a **pattern** instead. Pattern subscriptions are a first-class activation primitive, alongside event-type subscriptions and `where=` filters. A behavior can use any combination of the three; all three conditions must hold for the behavior to fire. ## Syntax Patterns are written in a strict subset of Cypher: ```python @behavior( name="risk_escalator", pattern="(c:claim)-[:supports]->(e:evidence) WHERE c.confidence > 0.7", ) def risk_escalator(event, graph, ctx): for match in ctx.matches: claim = match.bindings["c"] evidence = match.bindings["e"] ... ``` `ctx.matches` is a list of `Match` objects, one per distinct binding combination that satisfies the pattern. Iteration is the developer's responsibility — the framework does not collapse matches into a single fire-per-event; each match is exposed and the behavior body decides what to do with them. ## What the v0.7 subset supports - **Node patterns:** `(var:type {prop: value, ...})`. Properties are equality-only; comparisons go in `WHERE`. - **Relationship patterns:** `(a)-[var:rel_type]->(b)` and `(a)<-[var:rel_type]-(b)`. Direction is required. - **Multi-hop:** `(a)-[:r1]->(b)-[:r2]->(c)`. - **`WHERE` clauses:** comparisons (`=`, `<>`, `<`, `<=`, `>`, `>=`), `AND`, `NOT`, `NOT EXISTS { ... }`. The full grammar is enforced by the parser in `activegraph/runtime/patterns.py`. Anything outside the subset raises [`UnsupportedPatternError`](https://docs.activegraph.ai/reference/errors/unsupported-pattern-error/index.md) at behavior-registration time, not at match time — the parser validates the pattern when the decorator runs. ## What the subset deliberately refuses The subset is small on purpose. A fuzzy superset of Cypher would let patterns appear to match input they did not actually match, which would corrupt the audit trail that pattern-driven behaviors are designed to preserve. Specifically refused (each with a documented workaround in the error message that fires): - **OR in WHERE clauses.** Register two behaviors, one per branch of the disjunction. - **`RETURN`, `WITH`, multiple `MATCH`.** Patterns observe; they don't compose pipelines. Express the pipeline as multiple behaviors chained through emitted events. - **Variable-length paths (`-[*]-`).** Unbounded match cost. Express as N separate one-hop patterns if the lengths are bounded. - **`OPTIONAL MATCH`.** No null binding. Register a second behavior whose pattern is the optional sub-pattern. - **Aggregation, `UNWIND`, `UNION`.** Iterate in the behavior body instead — `ctx.matches` is the iteration surface. - **`CREATE`, `MERGE`, `SET`, `DELETE`, `DETACH`.** Patterns observe; they don't mutate. Mutations go in the behavior body via `graph.add_object`, `graph.patch_object`, `graph.remove_object`. CONTRACT v0.7 #8 locked the subset and is the canonical reference for why each refusal stands. ## Composition with event-type and `where=` subscriptions Pattern subscriptions combine with the other activation conditions: ```python @behavior( name="contradiction_detector", on=["object.created"], where={"object.type": "claim"}, pattern="(c:claim)-[:contradicts]->(other:claim)", ) ``` This behavior fires when **all three** conditions hold: an `object.created` event occurred, the new object's type is `claim`, and the new claim has an outgoing `contradicts` edge to another claim. The pattern is evaluated against the graph at the time the event fires; the new object is present in the match if the pattern references it. ## When to use the relationship variable `(a)-[r:type]->(b)` binds `r` to the relation object so the behavior body can read its properties. `(a)-[:type]->(b)` binds nothing; the relation is part of the match but its properties aren't available. Use the variable form when the behavior needs to read the relation; omit it when the relation is just a structural constraint. ## Tracing pattern fires Each behavior fire produced by a pattern subscription emits a `pattern.matched` event ahead of the `behavior.started` event. The trace shows how many matches the pattern produced for that fire: ```text [pattern.matched] evt_042 contradiction_detector matches=2 [behavior.started] contradiction_detector ``` The match count is also in the event's payload for downstream code that wants to subscribe to pattern matches without owning the behavior. ## Related - [`UnsupportedPatternError`](https://docs.activegraph.ai/reference/errors/unsupported-pattern-error/index.md) — what fires when a pattern uses syntax outside the subset. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the broader behavior model. Pattern subscriptions are one of three activation conditions. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — the framework's stance on what counts as a recoverable failure. The "refuse rather than fuzzy-match" choice for the pattern subset is one application of the broader principle. # Policies A policy is a runtime-attached rule that gates changes to the graph before they land. A behavior proposing a graph mutation under a policy doesn't get a direct apply — the change becomes an **approval** in `proposed` state. An operator (or an auto-approve setting) then approves the proposal, and the change lands. Policies are how the framework lets an operator sit in the loop without rewriting the behavior. The behavior says "I want to add this memo"; the policy says "memos require explicit approval"; the operator says "yes, approve it." The same behavior runs in dev (auto-approve) and prod (explicit-approve) without code changes. ## What gets gated Two operations can be policy-gated: - **Object proposals via `ctx.propose_object(type, data, reason)`.** Instead of an immediate `add_object`, the framework creates a pending approval and emits `approval.proposed`. The object lands only when the approval is granted. - **Patches.** A patch declared as policy-gated takes the same proposed-and-approved path, except the patch lifecycle lives in the patch event types (`patch.proposed` / `patch.applied`) rather than approval event types. See [`patches`](https://docs.activegraph.ai/concepts/patches/index.md) for the patch state machine. Object proposals are the more common shape. The diligence pack's `memo_approval` and `risk_approval` policies are the canonical examples: the pack declares which object types require approval, the operator decides per-instance. Not every change is policy-gated. Direct `graph.add_object`, `graph.patch_object`, and `graph.emit` calls land immediately; they're for changes the behavior author decided don't need operator review. The behavior chooses by calling the proposal method instead of the direct method. ## The approval lifecycle ```text proposed ──approve──> granted | └──deny────────> denied ``` Both transitions are one-shot. A proposed approval becomes granted exactly once (via `runtime.approve(id, approved_by=...)`) or denied exactly once (via `runtime.deny(id, denied_by=..., reason=...)`). Calling either on an already-terminal approval raises [`approval-not-found-error`](https://docs.activegraph.ai/reference/errors/approval-not-found-error/index.md) — the approval id is consumed by the transition. Each transition emits an event: - `approval.proposed` — carries the proposal kind (`object` / `patch`), the type, the data, the reason from the proposing behavior, and the pack that owns the gating policy. - `approval.granted` — carries the approval id, the approver identity, and the resulting object id (or applied patch id). - `approval.denied` — carries the approval id, the denier identity, and the denial reason. The events sit in the log alongside everything else. Downstream behaviors can subscribe to them; replay reconstructs the full proposal-and-decision sequence; the trace renders them. ## Declaring policies Packs declare policies as part of their `Pack(...)` declaration: ```python from activegraph.packs import Pack, PackPolicy pack = Pack( name="diligence", version="0.1.0", policies=[ PackPolicy( name="memo_approval", requires_approval=["memo"], settings_key="auto_approve_memos", ), ... ], ... ) ``` `requires_approval` lists the object types the policy gates. `settings_key` names the pack-settings boolean that controls auto-approve behavior; when `True`, the framework approves every proposal automatically and the behavior runs as if the policy weren't there. When `False`, every proposal pauses until an operator decides. The pack ships with the policies; the runtime instance decides auto-approve via its `DiligenceSettings(auto_approve_memos=...)`. That separation lets one pack run in different approval modes across environments. ## How a behavior proposes A behavior that wants its change to flow through a policy calls `ctx.propose_object` instead of `graph.add_object`: ```python @behavior(name="memo_synthesizer", on=["claim.completed"]) def memo_synthesizer(event, graph, ctx): ... ctx.propose_object( "memo", data={"title": "Diligence memo", "body": "..."}, reason="diligence run complete", ) ``` The propose call returns an approval id. The runtime decides whether to apply immediately (auto-approve setting is `True`) or queue the proposal (setting is `False`). Either way, the behavior body completes; the approval lifecycle continues independently. If the behavior tries `ctx.propose_object` outside a runtime-bound context — typically a test fixture or a refactored helper — it raises [`runtime-context-required-error`](https://docs.activegraph.ai/reference/errors/runtime-context-required-error/index.md). ## The operator-facing recovery When auto-approve is off, the operator drives the lifecycle: ```python for pa in rt.pending_approvals(): print(pa.id, pa.kind, pa.object_type, pa.reason) # Approve one: rt.approve(approval_id, approved_by="operator-jane") # Or deny: rt.deny(approval_id, denied_by="operator-jane", reason="not yet") ``` The CLI surface for production approval workflows is in the [operating guide](https://docs.activegraph.ai/guides/operating-in-production/index.md). ## The events-not-exceptions principle applied A denied approval is an event (`approval.denied`), not an exception. A behavior whose proposal gets denied doesn't see a raised exception — it sees the denial in the event log if it subscribes to `approval.denied`. The runtime continues; the behavior author writes a retry-or-escalate behavior if denial needs a response. The exception case is misuse of the primitive — passing a nonexistent approval id to `approve` / `deny` — which fires `ApprovalNotFoundError`. See [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) for the broader principle. ## What's related - [`patches`](https://docs.activegraph.ai/concepts/patches/index.md) — the durable-change primitive that policies gate. Approvals and patches share the proposed-and- decided shape; patches are the lower-level primitive. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — where `ctx.propose_object` is called from. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — why denials are events. - [`approval-not-found-error`](https://docs.activegraph.ai/reference/errors/approval-not-found-error/index.md) — the exception for misuse of the approval API. - [Operating in production](https://docs.activegraph.ai/guides/operating-in-production/index.md) — production workflows for the operator side. # Replay Replay is reconstructing graph state from the event log. The graph is a projection of the log (see [`graph`](https://docs.activegraph.ai/concepts/graph/index.md)); replay is the operation that computes the projection. Every time you load a run from a store, fork a run, or strict-check a run, replay is what runs underneath. The framework guarantees that **replay is deterministic** given the event log. Two replays of the same log produce byte-identical graph state. That guarantee is the foundation for forking, strict-mode validation, and the audit-trail contract. ## What replay does Three operations trigger replay: - **`Runtime.load(url, run_id=...)`** — loads a persisted run. Replay reads every event from the store and rebuilds the in-memory graph state. - **`runtime.fork(at_event=...)`** — creates a new run sharing the parent's events up to the fork point. Replay reconstructs the shared prefix in the fork; new behavior fires after the fork point execute fresh. See [`forking`](https://docs.activegraph.ai/concepts/forking/index.md). - **`runtime.replay()`** (explicit) — re-applies the in-memory event log. Less common; used by tests and migration scripts that need to verify replay determinism without going through the store. The framework doesn't separate "load" from "replay" in the public API — `Runtime.load` is the canonical entry point. Replay is the verb the load uses. ## The cache layer For LLM and tool calls to replay deterministically, the framework caches their responses by content hash: - **LLM responses** are keyed on the prompt's full content hash (system message + user messages + model + tool definitions + output schema). Replay reads `llm.responded` events from the log, indexes them by their corresponding `llm.requested`'s prompt hash, and returns the cached response when a behavior re-fires with the same prompt. - **Tool responses** are keyed on the tool's name plus a deterministic hash of its arguments. Same mechanism — replay reads `tool.responded` events and serves them to re-firing behaviors. The cache makes replay cheap: no LLM calls, no tool execution, just event-log reads. The cost is the disk space for the responses in the store, which is bounded by the run's size. ## Strict mode vs permissive mode Replay runs in one of two modes: - **Permissive replay** (`replay_strict=False`, the default for `Runtime.load`). Events are re-emitted from the log; the runtime trusts the recording. The cache serves responses for any behavior whose prompt hash matches a recorded one. Behaviors whose prompt hash doesn't match get fresh LLM/tool calls (with the caveat that those calls land as new events in the new run's log, not the parent's). - **Strict replay** (`replay_strict=True`). Behaviors re-fire against the recorded seed and the framework compares the live event stream against the recorded one. Any drift fires [`replay-divergence-error`](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md) pinned to the first divergent event id. Strict mode is for verifying that the run is replayable — a green strict replay proves the run is reproducible. Permissive mode is for development workflows where behaviors are still being edited and divergence is expected. The fork primitive runs strict by default because a fork's value is its shared lineage with its parent. ## The determinism contract Replay determinism rests on the [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) determinism contract: same event, same graph state, same view → same mutations. Three rules from that contract that replay specifically depends on: - **No `random`, `datetime.now()`, or `uuid.uuid4()` in behavior bodies.** If the body needs these, get them from the event (which carries the recorded timestamp) or from the runtime's deterministic id generator. - **No I/O outside the framework's primitives.** Direct `requests.get` in a behavior body breaks replay — the response isn't in the cache. - **No mutable global state across behavior fires.** A counter in a module-level variable that increments per fire would diverge under replay. The framework doesn't statically enforce these rules. A behavior that breaks them runs fine on first execution; replay or fork discovers the violation as [`replay-divergence-error`](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md). ## When replay is invoked The triggers, restated for reference: - **Store load** — every `Runtime.load(url, run_id=...)` runs replay during construction. The graph state is rebuilt from the event log before any new work happens. - **Fork** — `runtime.fork(at_event=...)` runs replay up to the fork point in the new run, then resumes live execution from there. - **Explicit replay** — `runtime.replay()` rebuilds graph state from the current in-memory event log. Uncommon outside of tests and migration code. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the projection replay computes. Owns the "graph as projection of event log" principle. - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — the append-only history replay reads. - [`behaviors`](https://docs.activegraph.ai/concepts/behaviors/index.md) — the determinism contract that makes replay work. - [`forking`](https://docs.activegraph.ai/concepts/forking/index.md) — the operation that runs replay up to the fork point. - [`failure-model`](https://docs.activegraph.ai/concepts/failure-model/index.md) — events vs exceptions; why divergence is an exception rather than a silent event. - [`replay-divergence-error`](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md) — the strict-mode error case. # Forking A fork is a branch from a parent run at a specific event. The fork shares the parent's event log up to the fork point; from there, it has its own independent log. The two runs can be diffed, configured differently, and inspected side-by-side without touching the parent's state. Forking is what lets the framework answer "what would have happened if I'd done X differently?" — a question agentic systems need to answer routinely and most frameworks can't answer at all. The shared-lineage model plus the [cache layer](https://docs.activegraph.ai/concepts/replay/index.md) makes fork cheap (no LLM re-execution for the shared prefix) and honest (the fork's lineage is verifiable from the event log). ## The shared-lineage model A fork copies events from the parent run, in order, up to the `--at-event` cutoff. The cutoff is **inclusive** — events at the cutoff id and before are in the fork; events after are not. ```text parent: evt_001 ... evt_042 evt_043 evt_044 ... (continues) | +- fork from evt_042 v fork: evt_001 ... evt_042 evt_045 evt_046 ... (fork's own work) ``` The fork's events 1 through 42 are the parent's. Event 45 onward is the fork's own; event ids don't collide because the fork has its own run id and its own monotonic id generator. The shared prefix doesn't re-execute when the fork starts. The framework [replays](https://docs.activegraph.ai/concepts/replay/index.md) the prefix against the fork's in-memory graph, then resumes live execution from the cutoff. LLM and tool responses for the shared prefix are served from the cache — no new LLM calls, no new tool calls — which keeps fork cheap. ## The CLI surface The `--set` flag is part of the v1.1 release The `--set .=` flag below is documented in CONTRACT v1.0 but lands in v1.1 (see [CONTRACT v1.1 #1](https://github.com/yoheinakajima/activegraph/blob/main/CONTRACT.md#v11-1-cli-flags-specd-but-not-implemented)). Until then, use the Python-API form documented in [Fork with a pack-setting override (v1.0 — Python API)](https://docs.activegraph.ai/cookbook/common-patterns/#fork-with-a-pack-setting-override-v10-python-api) for fork-with-override workflows. ```bash activegraph fork \ --run-id \ --at-event \ --label \ --set .= \ --record ``` Three flags shape the fork: - **`--at-event`** — the cutoff. Required. - **`--set =`** — override a pack setting in the fork. The key is a dotted path into pack settings only (`diligence.confidence_threshold_for_review=0.9` is in scope; `runtime.budget.max_cost_usd=10` is out of scope). Multiple `--set` flags compose; type coercion is Pydantic's job; unknown keys fail loud at fork-time with a `RegistrationError`-style message naming the typo and the valid keys. - **`--record`** — mark the fork as a re-recording. Behaviors whose prompts changed since the parent run will be re-recorded rather than cache-hit; new cache entries land in the fork's events. `--set` is the primitive that makes "what if I'd configured this differently?" cheap. The semantics — pack settings only, fail loud on typos — are documented in the [CLI reference](https://docs.activegraph.ai/concepts/reference/cli/index.md). ## How the cache replays For events before the fork point, the cache serves recorded responses by content hash: - **LLM call with the same prompt hash** → cached response from the parent run's `llm.responded` event. - **Tool call with the same args hash** → cached response from the parent run's `tool.responded` event. - **LLM or tool call whose hash drifted from the parent** — expected only after `--set` changed something upstream. Without `--record`, the fork's strict replay fires [`replay-divergence-error`](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md); with `--record`, the fork accepts the new responses and records them as its own. The cache is per-store, indexed by run id. A fork that needs to re-execute the same prompt the parent already ran in a different run can't reach across — caches don't cross runs. (Migration is the primitive for moving runs across stores; see [Operating in production](https://docs.activegraph.ai/guides/operating-in-production/index.md).) ## When to fork vs when to use frames Both let parallel computations proceed in isolation. The difference is durability and replay: - **Fork** — a separate run with shared event-log lineage up to the fork point. Forks are durable, replayable, diffable. Use fork when the parallel branches might diverge permanently or need independent persistence. - **Frames** ([`frames.md`](https://docs.activegraph.ai/concepts/frames/index.md)) — sub-contexts within one run. The frame's events live in the same event log as everything else; replay is the whole run. Use frames when the parallel contexts are short-lived or semantically belong together. The decision rule: **if you'd want to inspect, diff, or migrate the two branches independently after the fact, use fork. If the branches converge back to a single output within the same run, use frames.** A common pattern is to start parallel work in frames, then fork only the branches worth keeping. Frames are the cheap parallel primitive; forks are the durable one. ## What's related - [`graph`](https://docs.activegraph.ai/concepts/graph/index.md) — the world state the fork projects from its event log. - [`events`](https://docs.activegraph.ai/concepts/events/index.md) — the append-only history forks share up to the cutoff. - [`replay`](https://docs.activegraph.ai/concepts/replay/index.md) — the operation that reconstructs the shared prefix in the fork. - [`frames`](https://docs.activegraph.ai/concepts/frames/index.md) — the in-run parallel primitive that complements fork. - [`patches`](https://docs.activegraph.ai/concepts/patches/index.md) — patches in a fork are independent of the parent's patches once the fork point passes. - [`replay-divergence-error`](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md) — the error case when strict-mode replay finds a divergent prompt hash or event stream. - [CLI reference](https://docs.activegraph.ai/concepts/reference/cli/index.md) — the full surface for `activegraph fork` and the surrounding commands. # Failure model The framework distinguishes two kinds of failure, and the distinction governs how you write behaviors, how you read errors, and how you build on top of the runtime. ## The principle > **Exceptions are for caller-facing failures the caller can reasonably catch and act on. Non-fatal stops — budget exhaustion, behavior failures, tool failures, approval denials — are events in the log. The distinction: exceptions interrupt control flow; events extend the audit trail. When in doubt, an event.** Behaviors that fail during a run don't raise out to your code. The runtime catches the exception, emits a `behavior.failed` event with the original exception's type, message, and `reason` code in the payload, and the loop continues. Other behaviors keep firing. The operator sees the failure in the trace; downstream code that subscribes to `behavior.failed` can react (alert, retry-with-different-args, escalate). The same shape applies to tools: a `ToolError` raised inside a tool body becomes a `tool.responded` event with `error.reason` set, and the calling behavior's loop reads the structured failure and decides what to do. The same shape applies to budget exhaustion: when a `max_*` limit is hit, the runtime emits `runtime.budget_exhausted` with the dimension in the payload and stops gracefully. No exception escapes to your code — you read the event from `runtime.status()` or from the trace. ## When exceptions are the right answer Exceptions are for failures the caller is making **right now, at this line of code**, and can reasonably catch: - Constructing a runtime with conflicting arguments (`InvalidRuntimeConfiguration`) - Looking up a behavior or tool that isn't registered (`BehaviorNotFoundError`, `ToolNotFoundError`) - Passing a malformed store URL (`InvalidStoreURL`) - Replaying a run whose recorded event stream doesn't match the live re-run (`ReplayDivergenceError`) - Calling `runtime.approve(id)` on an id that doesn't exist (`ApprovalNotFoundError`) These all interrupt the call. The caller catches the exception, fixes the input, and tries again. There's no audit-trail entry to preserve because the call never produced one. ## The exception hierarchy Every framework exception inherits from `ActiveGraphError`. Seven categories live one level down: ```text ActiveGraphError ├── ConfigurationError construction-time / API-call argument errors ├── RegistrationError behavior/tool/pack registration problems ├── ExecutionError runtime execution problems (escaped to the caller) ├── ReplayError replay/fork divergence ├── StorageError persistence problems ├── PatternError pattern subscription syntax errors └── PackError pack-specific runtime problems ``` Catch `ActiveGraphError` to catch every framework exception. Catch a category base to catch every leaf in that category. Catch a specific leaf when the recovery is leaf-specific. The category leaves also multi-inherit from Python builtins where it preserves existing catch sites: `EventNotFoundError` is also a `KeyError`, `InvalidStoreURL` is also a `ValueError`, etc. Existing code catching the builtin keeps working; new code can catch the category for richer context. ## The structured event types `behavior.failed`, `tool.responded` (with error), `runtime.budget_exhausted`, `approval.denied` — each carries a `reason` field with a stable discriminator code so downstream code can branch on the failure mode without parsing prose. The codes are documented in [Reference: Events](https://docs.activegraph.ai/concepts/reference/events/index.md). ## Observing failures in caller code The framework gives you two surfaces for noticing failures without subscribing a `@behavior` to `behavior.failed`: **1. The WARNING log line.** Every `behavior.failed` emission produces one log line at `WARNING` level on the `activegraph.runtime` logger: ```text WARNING activegraph.runtime: behavior failed: my_behavior (reason=llm.network_error) ``` The structured log record carries `behavior`, `event_id`, `reason`, `error_type`, `error_message`, and a `doc_url` pointing at the reason's documentation page. Operators tail logs and click through to the doc-page from the URL. Opt out via the standard Python logging API: ```python import logging logging.getLogger("activegraph.runtime").setLevel(logging.ERROR) ``` **2. The `Runtime.errors` property.** After a run, inspect failures programmatically without parsing event payloads: ```python rt.run_goal("...") for err in rt.errors: if err.reason == "llm.network_error": retry_with_backoff(err.event_id) ``` Each `err` is a `BehaviorFailure` named tuple with five fields plus the `behavior.failed` event id: | Field | Meaning | | ----------------- | -------------------------------------------------- | | `behavior` | the failing behavior's name | | `event_id` | the triggering event's id | | `reason` | the v0.6 #11 reason code (None for raw exceptions) | | `exception_type` | the Python exception class name | | `message` | the exception's `str(...)` | | `failed_event_id` | the `behavior.failed` event's id | The property reads from the graph's event log on each access — the events are the source of truth and the property is a structured projection. No caching, no listeners, no new runtime state. The two surfaces use different field names for the same values: the WARNING log line uses the v0.8 #6 structured-logging schema keys (`error_type` / `error_message`), while `BehaviorFailure` uses Python-conventional attribute names (`exception_type` / `message`). The values are identical — only the names differ. The two surfaces are *additive* and don't change the failure model: events stay the durable record, behaviors that fail still don't raise out of `run_goal()`, and existing code subscribing to `behavior.failed` keeps working unchanged. ## "When in doubt, an event" If you're writing a behavior and you're about to raise an exception because something downstream "should never happen," ask: - Can the caller reasonably catch and act on this? - Is the failure attributable to a specific event in the log? If the answer to the first is "no" and the answer to the second is "yes," emit an event instead. The audit trail is the durable record; exceptions are just the runtime's way of refusing the current call. This rule is what kept `BehaviorFailedError` and `BudgetExhaustedError` out of the framework's exception hierarchy. Both were considered during the v1.0 error-rewrite series and rejected because their information already lives in events. Adding them as exceptions would have surfaced two parallel failure surfaces — one in the trace, one in caller code — and the divergence is exactly the kind of subtle inconsistency that makes a framework feel unreliable six months in. # Guides # Operating Active Graph This document is for **operators**: people responsible for running an Active Graph runtime as part of a system other people depend on. The README is for developers writing behaviors. The audience is different and so is this document. If you are evaluating Active Graph, read the README first. If you have a behavior that doesn't run on your machine, the README will help. If you have a behavior that runs fine on your machine but you need to put it somewhere a team can rely on it, you are in the right place. The companion example is [`examples/operate_a_run.py`](https://github.com/yoheinakajima/activegraph/blob/main/examples/operate_a_run.py). Read it alongside this guide — every CLI command and library call shown here appears there. If the two ever disagree, the example is right. ______________________________________________________________________ ## The operator surface The framework treats the boundary between itself and the world it runs in as a load-bearing contract. Five primitives compose that surface; together they make a run inspectable, observable, and recoverable without reading source code: 1. **Postgres** as a second `EventStore`, behind the same protocol as SQLite. Same schema, same semantics, different driver. 1. **Structured logging** with a documented JSON schema. One log line per event, every line carries `run_id` / `event_id` when applicable. 1. **Metrics**: a three-method `Metrics` protocol with a `NoOpMetrics` default and a reference `PrometheusMetrics` implementation. The runtime emits a fixed, documented set of counters, histograms, and gauges. Custom backends (OpenTelemetry, Datadog, statsd) implement the protocol — three methods. 1. **`activegraph` CLI**: `inspect`, `replay`, `fork`, `diff`, `export-trace`, `migrate`, `pack`, `quickstart`. The CLI is a thin wrapper around library APIs; anything it does, programmatic callers can do too. 1. **Runtime introspection**: `runtime.status(recent=N)` returns a frozen snapshot of queue depth, budget remaining, registered behaviors, recent events, and current frame. The CLI's `inspect` command sits on top of this primitive. The operator surface introduced in v0.8 has been extended additively since: v0.9 added the pack format (and `activegraph pack` for listing/scaffolding); v1.0 added per-error reference pages ([Reference: Errors](https://docs.activegraph.ai/reference/errors/replay-divergence-error/index.md)) that every error message links to, plus operator-targeted CLI follow-on flags (`inspect --event ` for divergence triage, `inspect --behaviors` for replay length mismatches, `inspect --pack-version` for prompt-hash audits, `migrate --skip-corrupted` for corrupted-payload recovery, `fork --record` for intentional re-recording). What the framework deliberately does **not** ship: a web UI, an HTTP server, a distributed runtime, real-time subscriptions, multi-model LLM routing, or streaming LLM responses. The framework is small, sharp, and operable. Plug in adapters at the boundaries where you need them. ______________________________________________________________________ ## Persistence: SQLite vs Postgres SQLite is the default and the right answer for solo work, demos, ephemeral runs, and most single-machine production cases. The event log fits in one file, WAL mode gives you crash-safe writes, and you have no operational dependencies. Postgres is the right answer when: - More than one process or machine needs to inspect a run (the operator on a laptop, a dashboard, a CLI on a jump box, a CI job). - You already operate Postgres and want one fewer storage system. - You want to put the JSONB column behind a read replica or pipe it into your data warehouse. Both stores conform to the same `EventStore` protocol. The runtime, the CLI, and every library API treat them identically. **Migration is one-directional and explicit** (see below). ### Connection URLs Stores are addressed by URL throughout the framework — runtime, CLI, library APIs. The schemes follow the SQLAlchemy convention: - `sqlite:///relative/path.db` (**three** slashes — relative path) - `sqlite:////absolute/path/to/run.db` (**four** slashes — absolute path; the leading `/` of the absolute path adds the fourth slash) - `postgres://user:password@host:port/dbname` - `postgresql://user:password@host:port/dbname` (same scheme) A path with no scheme is an error. The framework will not guess. `activegraph inspect run.db` will fail with a message pointing here. Use `sqlite:///run.db` (relative) or `sqlite:////tmp/run.db` (absolute). ### Postgres setup ```bash # Postgres 16 or newer, anywhere reachable from the runtime. createdb activegraph_prod # Schema is created lazily on first connection. No migration step. pip install 'activegraph[postgres]' # pulls psycopg>=3.1,<4 ``` The first time the runtime opens a Postgres URL it issues `CREATE TABLE IF NOT EXISTS` for `events`, `runs`, and `meta`, mirroring the SQLite schema with Postgres-native types (`BIGSERIAL`, `JSONB`, `TIMESTAMPTZ`). Schema version is stored in `meta` and verified on every open. A schema version mismatch is a hard error — the runtime refuses to operate on a log it does not understand. ### Connection management `PostgresEventStore` accepts: 1. A URL string. The store opens a single dedicated connection. 1. A `psycopg.Connection` you already have. The store does not own its lifecycle — you must close it. 1. A `psycopg_pool.ConnectionPool`. The store will `getconn()` / `putconn()` around each operation. For production, pass a pool. The framework does not ship its own pool because we are not in a position to make tuning decisions for your deployment. ```python import psycopg_pool from activegraph.store.postgres import PostgresEventStore pool = psycopg_pool.ConnectionPool( conninfo="postgres://localhost/activegraph_prod", min_size=2, max_size=10, ) store = PostgresEventStore(pool, run_id="run_01J...") ``` ### Migration (transaction-per-run) ```bash activegraph migrate --from sqlite:///path/to/dev.db \ --to postgres://localhost/activegraph_prod ``` Migration semantics: - Each run in the source migrates in **a single transaction** against the destination. If a run fails partway, that run's destination state is unchanged (Postgres rolls back). - Migration is **idempotent** at the event level: writes use `INSERT ... ON CONFLICT DO NOTHING` against the `UNIQUE(id, run_id)` index. Re-running migration after a partial failure resumes safely. - Runs are migrated independently. A bad run does not block the others. - The default migrates **all** runs in the source. To pick one: `--run-id `. - A per-run report is printed at the end (machine-readable with `--json`). Each entry is `{run_id, status, events_migrated, error?}`. The CLI exit code is non-zero iff any run failed. - Migration is **not bidirectional**. There is no `sync` mode and no rollback. To go back, migrate the other direction. When migration is the right tool: you are graduating a run from a laptop SQLite file to a shared Postgres database, or moving a historical archive between Postgres instances. When it is the wrong tool: you are trying to keep two stores in sync. Don't. ______________________________________________________________________ ## Structured logging The framework emits structured logs through stdlib `logging`. **It does not auto-configure logging on import** — a library that does is hostile to operators who have already configured their own. By default the framework logs to `logging.getLogger("activegraph")` and lets your config handle the rest. If you want the opinionated setup: ```python from activegraph.observability import configure_logging configure_logging(level="INFO", json_output=True) ``` That installs a JSON formatter on the `activegraph` logger hierarchy. Every log line becomes one JSON object on one line, suitable for ingestion by Loki, Splunk, BigQuery, Cloud Logging, or any other line- oriented log aggregator. ### Log schema Every line is a JSON object. These fields appear when applicable. Fields that don't apply are **omitted**, not nulled: | Field | Type | When | | ----------------- | ------ | ------------------------------------------------------------ | | `timestamp` | string | always (ISO 8601, UTC) | | `level` | string | always (`DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`) | | `logger` | string | always (e.g. `activegraph.runtime`) | | `message` | string | always | | `run_id` | string | any log line associated with a specific run | | `event_id` | string | log lines about a specific event | | `behavior` | string | log lines about a specific behavior invocation | | `tool` | string | log lines about a tool invocation | | `model` | string | log lines about an LLM call | | `cache_hit` | bool | LLM/tool calls; true if served from cache | | `cost_usd` | string | LLM calls that incurred cost (Decimal-as-string) | | `latency_seconds` | number | LLM/tool/behavior calls with measured latency | | `reason` | string | failure log lines (see reason taxonomy) | | `error_type` | string | failure log lines | | `error_message` | string | failure log lines | The schema is **the operator contract**. Dashboards built against these field names will keep working across framework versions. Breaking the schema is a breaking change. ### Level discipline | Level | What | | -------- | ------------------------------------------------------------ | | DEBUG | View construction, prompt assembly, cache lookup, queue ops | | INFO | Every event emitted, every behavior invoked, every tool call | | WARNING | Budget approaching limits, retries, pattern eval slowness | | ERROR | `behavior.failed` with non-budget reasons | | CRITICAL | Event log inconsistency, schema mismatch, replay divergence | INFO is a high-volume stream in any active run. Operators typically filter at WARNING for production dashboards and crank to DEBUG when debugging. There are no `print()` calls anywhere in the framework. The trace printer (`runtime.print_trace()`) is a developer tool, not an operator tool — it prints to stdout, does not log, and is independent of the logging configuration. ### Payload redaction LLM behaviors include rendered prompts in DEBUG logs. Tool responses include their full payloads. Goals can contain anything the user typed. If your environment requires redaction (PII, secrets, customer data): ```python def redact(payload: dict) -> dict: return {k: ("" if k == "email" else v) for k, v in payload.items()} configure_logging(level="INFO", json_output=True, payload_redactor=redact) ``` The redactor runs on every payload that would otherwise appear in a log message. It does not affect the event log itself — the source of truth keeps the original. Redaction is a logging concern. ______________________________________________________________________ ## Metrics The framework emits metrics through a three-method `Metrics` protocol: ```python class Metrics(Protocol): def counter(self, name: str, tags: dict[str, str], value: float = 1.0) -> None: ... def histogram(self, name: str, tags: dict[str, str], value: float) -> None: ... def gauge(self, name: str, tags: dict[str, str], value: float) -> None: ... ``` That's it. Three methods. No timers (use a histogram with a latency value). No summaries (Prometheus-specific). No custom types. ```python from activegraph.observability import PrometheusMetrics rt = Runtime(graph, metrics=PrometheusMetrics()) ``` The default is `NoOpMetrics`, which does nothing. The runtime is fully functional with no metrics configured. `PrometheusMetrics` lazy-imports `prometheus_client`. Install with `pip install 'activegraph[prometheus]'`. For OpenTelemetry, Datadog, statsd, or anything else: write a class with three methods. We do not ship adapters. ### Standard metrics These metrics are emitted by the runtime. Names follow Prometheus conventions (snake_case with underscores, `_total` for counters, `_seconds` for duration histograms, `_usd` for cost histograms). They are the **operator contract**: dashboards built against these names keep working across framework versions. | Name | Type | Tags | | -------------------------------------------------- | --------- | -------------------- | | `activegraph_events_emitted_total` | counter | `event_type` | | `activegraph_behaviors_invoked_total` | counter | `behavior` | | `activegraph_behaviors_failed_total` | counter | `behavior`, `reason` | | `activegraph_behaviors_duration_seconds` | histogram | `behavior` | | `activegraph_llm_calls_total` | counter | `model` | | `activegraph_llm_cache_hits_total` | counter | `model` | | `activegraph_llm_failed_total` | counter | `model`, `reason` | | `activegraph_llm_tokens_in` | histogram | `model` | | `activegraph_llm_tokens_out` | histogram | `model` | | `activegraph_llm_cost_usd` | histogram | `model` | | `activegraph_tools_calls_total` | counter | `tool` | | `activegraph_tools_cache_hits_total` | counter | `tool` | | `activegraph_tools_failed_total` | counter | `tool`, `reason` | | `activegraph_tools_duration_seconds` | histogram | `tool` | | `activegraph_queue_depth` | gauge | (none) | | `activegraph_budget_cost_remaining_usd` | gauge | `run_id` | | `activegraph_budget_events_remaining` | gauge | `run_id` | | `activegraph_patterns_evaluated_total` | counter | (none) | | `activegraph_patterns_evaluation_duration_seconds` | histogram | (none) | | `activegraph_replay_divergence_detected_total` | counter | `reason` | **Adding a metric is a public API change.** The list is documented and test-pinned. New metrics get added in named releases, not silently. ### Cardinality rule (locked) > `run_id` MAY appear as a tag on **gauges of active state** (where cardinality is bounded by the number of concurrently active runs). `run_id` MUST NOT appear as a tag on **counters or histograms**. This rule prevents the most common Prometheus operational disaster: unbounded cardinality from per-run labels accumulating forever. The budget gauges are the only exception, and they live only for the duration of a run. The conformance suite enforces this rule against the standard metric list. If you implement a custom `Metrics` backend, do the same. ### Tag conventions Standard tag keys are: `event_type`, `behavior`, `tool`, `model`, `reason`, `run_id` (gauges only). Boolean tags (`cache_hit` is modeled as a separate counter rather than a tag — see `activegraph_llm_cache_hits_total`). If your backend distinguishes booleans from strings, you won't have to special-case. Custom tags beyond the standard set are fine but may explode cardinality. The cardinality rule above is your guide. ______________________________________________________________________ ## Runtime introspection `runtime.status(recent: int = 20)` returns a `RuntimeStatus` — a frozen dataclass. Calling it is cheap: no graph traversal, no event log scan. It is safe to call from any thread. ```python status = rt.status() print(status.run_id, status.state, status.queue_depth) for ev in status.recent_events: print(ev.id, ev.type) ``` Shape: ```python @dataclass(frozen=True) class RuntimeStatus: run_id: str state: Literal["idle", "running", "stopped", "exhausted"] queue_depth: int events_processed: int budget: BudgetSnapshot frame: FrameSnapshot | None registered_behaviors: list[BehaviorInfo] recent_events: list[EventSummary] ``` `recent_events` length is `recent` (default 20). The CLI's `inspect --tail N` passes through to this argument. There is **no `last_error` field**. Errors are events. Filter `recent_events` for type `behavior.failed`, or query the event store directly for a window-independent view. ______________________________________________________________________ ## CLI The `activegraph` binary is a thin wrapper around library APIs. Every subcommand calls into Python; nothing is implemented in the CLI itself. A programmatic user can do everything the CLI does. ```text activegraph inspect [--run-id ] [--tail N] [--json] [--event | --behaviors | --pack-version] activegraph replay --run-id [--json] activegraph fork --run-id --at-event --label