Skip to main content

A graph engineering toolkit on LangGraph: typed state contracts, per-node write permissions, enforced budgets, and JSONL traces that double as replay points.

Project description

GraphARC

PyPI Python CI License

A governed agent runtime built on LangGraph.

Build production-grade multi-agent systems with built-in safety, auditability, and control. GraphARC adds a governance layer on top of LangGraph: a planner proposes a subgraph, a deterministic checker admits it, and only then does anything execute. Every transition is permitted, every loop is bounded, and afterwards you can prove what happened and why it stopped.

Status: Early days (0.1.2) — the API is not stable yet. pip install grapharc — see Install.

What makes GraphARC different

  • Admission gate: All runtime topology changes go through deterministic approval before execution
  • Typed state contracts: Pydantic-based state validation at every node boundary
  • Per-node write permissions: Explicit control over what each node can modify
  • Enforced budgets: Token spend and execution time limits with hard cutoffs
  • Complete auditability: JSONL traces that serve as replay points and proof of execution
  • Governed topology: New nodes and edges proposed at runtime are checked before being built

Graph engineering: when one agent loop stops being enough, coordination becomes the engineering. Nodes do work (agent loops, model calls, deterministic functions, humans approving things), edges decide what runs next, and a typed shared state flows between them. GraphARC implements the discipline that makes such graphs production-grade rather than demos — the ideas emerging from the July 2026 loops-vs-graphs debate (Steinberger, Ng, et al.), the "Two Graphs, Two Jobs" split, and twenty years of pre-AI graph systems where every edge means something and every path can be explained.

Table of Contents

Quick Start

from grapharc.runtime import StateGraph
from pydantic import BaseModel

# Define your state
class MyState(BaseModel):
    messages: list[str]
    result: str = ""

# Create a graph
graph = StateGraph(MyState)

# Add nodes and edges
graph.add_node("process", lambda state: {"result": "done"})
graph.add_edge("START", "process")
graph.add_edge("process", "END")

# Compile and run
compiled = graph.compile()
result = compiled.invoke({"messages": ["hello"]})

See Usage for more detailed examples.

Architecture

The GraphARC architecture: a CLI or HTTP request reaches a planner, which emits a typed proposal; a deterministic admission checker either refuses it with reasons or admits it; only an admitted proposal is materialised and run by the graph kernel, on top of the model, tool and memory planes; everything lands on one JSONL record, and work discovered mid-run re-enters the gate.

The amber curve along the top is the claim: refusals return as traced reason codes, and work discovered mid-run re-enters admission — there is no already-approved path and no cached authorisation.

For detailed architecture views, see docs/diagrams/grapharc-architecture.drawio and five more views generated from architecture.py.

Core Components

Component Purpose Module
Kernel Typed state contracts, declared writes, budgets, traces, fan-out, async support grapharc.runtime
Planner + Admission Propose subgraphs, admit/reject with reasons, materialise, replan grapharc.planner
Agent Node Observe → model → permission check → sandboxed tool → repeat loop grapharc.harness
Tools Seven core tools with workspace confinement; container executor grapharc.tools
Sessions Long-lived, resumable across processes, human approval gates grapharc.session
HTTP API FastAPI + Server-Sent Events for streaming grapharc.server
Policy TOML rules over nodes, edges, tools and spend; decision audit trail grapharc.policy
Memory Durable claims with provenance, artifacts, BM25F + graph retrieval grapharc.memory
Observability Replay, run diffing, OpenTelemetry spans, cost attribution grapharc.observe

Every component above is reachable from a shipped command. See ROADMAP.md §12 for known gaps — the HTTP API still runs its own in-process session layer instead of the durable one.

What it adds on top of LangGraph

Everything in this table is enforced by the library rather than left to convention, and has a test you can run.

Discipline Mechanism
Write permissions Every node declares which state fields it may write. An undeclared write raises WritePermissionError; plain LangGraph applies it and moves on.
State isolation Nodes receive state.model_copy(deep=True), so mutating a nested model in place cannot sneak past the declared write channel — the returned dict is the only way out of a node.
Typed state Schemas are Pydantic models with extra="forbid". Declaring a write to a field that doesn't exist fails when the node is added, not when it runs.
Earned cycles dag=True rejects conditional and fan-out edges when they're added, and cycles at compile time — Stage 0 before Stage 2.
Code-only routing Routers are ordinary Python functions over typed state, so model prose cannot steer an edge. A node may also return Command(goto=…) for dynamic routing — still code — and the destination is validated against the compiled graph at the node boundary.
Convergence ProgressGuard returns the first triggered StopReason (target met / no progress / round cap), so a cycle ends with a machine-readable reason instead of running out of road.
Traces Each node execution writes JSONL start / end / error events. start carries only the identity of the step (run, thread, attempt, graph, node, step, timestamp); end adds the state delta, duration and tokens; error adds the duration and the exception. metrics, viz, replay, diff and the OTel exporter read that same file, so the dashboard and the audit trail cannot disagree.
Fail-closed entry points Driving a compiled graph through raw LangGraph (.inner.invoke()) raises MissingRunContextError rather than silently running with no budget and no trace.
Bounded work A per-run Budget (iterations / tokens / seconds / concurrency). Iterations and tokens are metered by the runtime itself, and max_seconds is delivered as an interrupt into the running node rather than only checked between them.
Checked state edits update_state() is not a passthrough: it rejects unknown fields, type-checks the values, and — given as_node= — applies that node's declared write allowlist.
Governed topology New nodes and edges proposed at runtime go through a deterministic admission gate before anything is built. See The admission gate.

Three of those need their edges stated, because the gap is where people get hurt.

Budgets. Tokens are charged without the node's cooperation: a LangChain callback is installed for the duration of every node, so any chat model invoked on that thread reports usage to the run's meter — including calls buried inside library code the node merely calls — and the ceiling is enforced at the node boundary. max_seconds is an interrupt, not a poll: SIGALRM on the main thread, an asynchronous exception otherwise, so a node parked in time.sleep or on a provider's socket is cut off at the deadline. Where it stops short: spend a provider never reports cannot be charged, a model invoked on a thread the node started itself is outside the callback's context, and an async exception cannot unwind a thread sitting inside a C call — it lands when that call returns. Even then the deadline holds at the node boundary: a node that overran does not get its writes into state.

Routing. The routers are code, which is the property that matters: no model output is ever consulted to pick an edge. But add_conditional_edge passes the router and its mapping straight through to LangGraph — GraphARC does not verify that the router's return value is a key in the mapping, so a typo surfaces as a KeyError at run time rather than when the edge is added.

Typing. Writes are checked in both directions: the dict a node returns is validated field by field against the state schema before it lands, and the state is validated again when the next node receives it. A value that doesn't fit raises StateTypeError naming the node, the field, the declared type and what arrived — and that includes the last node before END, so a bad type no longer escapes into the result. The validated value is what gets written, so a schema that says int means the result holds an int. The remaining gap is narrow and worth stating exactly: write-time validation is built from each field's annotation, so constraints carried in the annotation (Annotated[int, Field(gt=0)]) do bite, but a validator the state model declares for itself — @field_validator, @model_validator — is not run on a write. A node returning {"slug": "NOT-LOWER"} into a field whose validator demands lowercase is accepted, even though constructing the model directly with that value raises; the violation surfaces only when a later node receives the state and the whole model is rebuilt, which means one written by the last node before END still reaches the result. The write allowlist is GraphARC's; the types are Pydantic's.

Crash-safe resume is LangGraph's, not GraphARC's: a checkpointer handed to compile() goes straight to StateGraph.compile(). What GraphARC adds on top is trace continuity — after a resume, step numbers continue from the thread's history and the attempt counter increments, so replay points stay unique across attempts. What grapharc.session adds on top of that is everything the kernel deliberately does not know about: who is driving the thread, what has been said to it since it last ran, and whether a human still has to sign something off.

Async is carried through. ainvoke, astream and astream_events all run through the same disciplined path — budgets, traces and write permissions apply unchanged — and async def nodes execute. The sync entry points refuse a graph containing them with AsyncNodeError before anything runs, rather than letting LangGraph execute every sync node first and fail at the first coroutine. astream_events offers v1 and v2; v3 is refused because LangGraph returns a stream object there rather than an async iterator, which is a different contract than the method's.

Install

Python >= 3.12.

pip install grapharc          # or: uv pip install grapharc
grapharc demo stage0          # costs nothing, needs no key

That is the whole install. The bare package carries the kernel, the planner and the admission gate, the agent harness, the seven core tools, memory, policy and the observability commands — everything the Quickstart below runs. Backends and the HTTP API are extras, because each pulls dependencies you should not pay for unless you use them:

pip install 'grapharc[openrouter]'   # tool calling, structured output
pip install 'grapharc[openai]'       # OpenAI and OpenAI-compatible endpoints
pip install 'grapharc[ollama]'       # a local server
pip install 'grapharc[server]'       # the FastAPI + SSE HTTP API
pip install 'grapharc[otel]'         # OpenTelemetry span export
pip install 'grapharc[slack]'        # run the CLI from Slack — docs/cookbook/07-slack.md
pip install 'grapharc[all]'          # every one of the above

The Claude CLI backend — the default — needs no extra: it shells out to claude on your PATH. Run grapharc models --check to see what your machine can actually reach.

Working on GraphARC itself, rather than with it:

git clone https://github.com/CodeGraphContext/GraphARC
cd GraphARC
uv sync --group dev              # Python >= 3.12
uv sync --all-extras --group dev # everything, plus the dev group

Quickstart

grapharc demo stage0        # deterministic DAG: load -> split -> count -> report
grapharc demo stage1        # one earned agent loop: discover -> act -> verify -> repeat
grapharc demo stage2        # typed cyclic graph: extract claims -> verify -> retry
grapharc demo stage3        # bounded fan-out with failure isolation and dedup
grapharc demo stage4        # bounded investigation loop with convergence guards
grapharc demo stage5        # verifier: fresh context + deterministic evidence anchor
grapharc demo stage6        # memory: provenance, supersession, recall
grapharc demo capstone      # all of the above in one research agent

grapharc plan "look into the outage"   # governed loop: propose -> admit -> execute -> replan
grapharc plan "..." --approve          # park each admitted round until a human answers
grapharc approve <trace>               # answer a parked run (--deny to refuse)
grapharc run graph.json                # a topology you wrote, through the same gate
grapharc run graph.json --check-only   # admission as a linter; executes nothing

grapharc agent "fix the failing test" --workspace ./sandbox   # agent + the seven core tools
grapharc serve --port 8000       # the HTTP API (needs the `server` extra)
python -m grapharc.slack         # the same commands from Slack (needs the `slack` extra)

grapharc models                  # what a model spec resolves to
grapharc trace <path>            # pretty-print a run trace
grapharc metrics <path> <run-id> # tokens, retries, termination reason, per-node counts
grapharc viz <path> <run-id>     # Mermaid diagram: the declared graph, execution status overlaid
grapharc replay <path> <run-id>  # reconstruct a run from its trace
grapharc diff <path> <a> <b>     # what changed between two runs

Twelve commands, and every one of them takes --json — in JSON mode the failure is the document rather than a line on stderr. Exit codes are part of the interface: 0 did the job, 1 ran and the answer was negative (an agent stopped short, a run id had no events, two runs differed), 2 could not run at all.

The Slack bot puts most of these commands one /grapharc … away from a phone, behind an allowlisting gate that keeps the default spend at zero — setup in docs/cookbook/07-slack.md, and a command-by-command session, refusals included, in docs/cookbook/08-slack-walkthrough.md. A tracing command run from Slack is narrated live — one status message edited in place as nodes run, with a refreshed diagram link — and grapharc serve --live-root adds a browser page that redraws the orchestration graph in real time over SSE.

The run stages use scripted models by default, so they cost nothing and produce the same trace every time. Add --model to run one against a real backend — that works for stage1 through stage6 and the capstone; stage0 is pure code with no model in it. grapharc agent is the exception: it needs a tool-calling backend and says so rather than degrading, because a scripted model has no bind_tools to drive a tool loop with.

Building a graph:

from grapharc import GraphARC, GraphARCState, Budget
from grapharc.runtime.graph import START, END

class State(GraphARCState):
    question: str
    answer: str = ""

def answer(state: State) -> dict:
    return {"answer": f"42 (asked: {state.question})"}

g = GraphARC(State, name="demo", budget=Budget(max_iterations=10))
g.add_node("answer", answer, writes={"answer"})   # undeclared writes raise
g.add_edge(START, "answer")
g.add_edge("answer", END)
print(g.compile().invoke({"question": "meaning of life"}))

The admission gate

The part with no prior art to copy, and the reason the rest exists. You cannot pre-author a graph for "investigate this incident" — the shape is discovered while working. So the graph is built at runtime, and a deterministic checker stands between building it and running it.

Watch it happen first. This costs nothing and needs no key — the shipped planner is scripted, and its first proposal names the policy-denied deploy kind:

grapharc plan "investigate the checkout outage"
goal      : investigate the checkout outage
model     : scripted
registry  : grapharc.examples.plan_incident:build_registry
kinds     : deploy, patch, triage, verify
policy    : grapharc.examples.plan_incident:build_registry default (deny -> deploy, otherwise allow)  [registry-default]
config    : no grapharc.toml (flags and defaults only)

stopped   : goal_met  (the goal check was satisfied)
rounds    : 2 of max 8
   round 1: rejected  nodes=2 executed=False  rejected: edge_denied
   round 2: admitted  nodes=3 executed=True

state     : goal='investigate the checkout outage' notes=['triage ran', 'patch ran', 'verify ran']

Round 1 wanted to deploy and never executed. Round 2 went through the same checker and ran.

The policy line ends in [registry-default] — that is the provenance, and it is on the JSON payload too as policy_source. It matters because a policy can now come from four places: a --policy flag, a grapharc.toml, one an LLM generated on a first run, or the registry's own default. A generated run and an authored one look identical on the command line, so the source is the only thing that tells them apart afterwards.

--model SPEC swaps in a real backend and changes none of the enforcement; --policy policy.toml moves the rules into a document.

Here is the assembly, complete and runnable as written:

from pydantic import BaseModel

from grapharc.harness.permissions import Decision
from grapharc.planner import (
    AdmissionChecker, CostEstimate, EdgePolicy, EdgeRule, GovernedLoop,
    LoopLimits, Materializer, NodeRegistry, NodeSpec, PlannerNode,
)
from grapharc.runtime.budget import Budget
from grapharc.testing import ScriptedChatModel


class State(BaseModel):
    found: str = ""
    fixed: str = ""


def factory(spec):                              # bodies come from HERE, never a proposal
    def body(state):
        return {"found": "cause"} if spec.name == "search" else {"fixed": "patch"}
    return body


registry = NodeRegistry([                        # the kinds a planner may propose
    NodeSpec(name="search", factory=factory, worst_case=CostEstimate(tokens=500)),
    NodeSpec(name="edit",   factory=factory, worst_case=CostEstimate(tokens=2000)),
    NodeSpec(name="deploy", factory=factory),
]).freeze()
policy = EdgePolicy(rules=(                      # deny -> ask -> allow, unmatched is deny
    EdgeRule(action=Decision.DENY, target="deploy"),
    EdgeRule(action=Decision.ALLOW),
))

plan = '{"nodes": [{"name": "%s"}], "edges": [{"source": "__start__", "target": "%s"}]}'
loop = GovernedLoop(
    planner=PlannerNode(
        ScriptedChatModel(responses=[plan % ("deploy", "deploy"), plan % ("edit", "edit")]),
        catalog=registry.catalog(),
    ),
    checker=AdmissionChecker(registry=registry, edge_policy=policy),
    materializer=Materializer(
        registry=registry, state_schema=State,
        writes={"search": {"found"}, "edit": {"fixed"}, "deploy": set()},
    ),
    budget=Budget(max_tokens=100_000),
    limits=LoopLimits(max_rounds=8),
    goal_reached=lambda s: bool(s.fixed),
)
result = loop.run("find and fix the bug", State())

print(result.stop.value)
for record in result.rounds:
    print(record.round, record.admission.status.value, record.executed)
print([r.code for r in result.rejections()])

Output:

goal_met
1 rejected False
2 admitted True
['edge_denied']

The trace holds an admission event per round, a round event per round, the executed nodes' own start/end pairs and one stop event, all under one run_id — so what it did, what it was allowed to do and why it stopped are answerable from the file alone. Round 7 is checked by the same checker as round 1; there is no already-approved path.

Both blocks above are executed by tests/test_readme.py, which byte-compares this page's output against a real run, so neither can rot quietly.

Five checks, all of which run on every proposal, so a planner gets the complete list of objections rather than the first one: is every node's kind in the registry, is every edge permitted between the kinds it joins, does the worst case fit what is left of the budget, is the nesting within the depth limit, and is it acyclic.

What that buys, stated as properties rather than adjectives:

  • Nothing runs during a check. NodeSpec.factory is never called and the budget meter is read, not written. An over-budget proposal is refused before its first node exists.
  • A rejection is data. There is no "admit a reduced version", no truncated fan-out, no downgraded edge. AdmissionResult.feedback() — the per-check list with codes and remedies — is handed back to the planner as its next round's input, and the loop never retries an identical proposal, because the checker is deterministic and would give the same answer.
  • Costs come from the registry, never the proposal. A planner cannot buy admission by claiming to be cheap.
  • Every decision keys on the registry kind, never the instance name. ProposedNode(name="harmless_helper", kind="deploy") is refused by a rule denying deploy. Renaming cannot launder a kind, and naming an instance after a permitted kind cannot borrow its permission.
  • A proposal cannot carry code. Subgraph and ProposedNode are Pydantic models with extra="forbid", so a body= or fn= key is a validation error. Every node body comes from a factory the operator registered before any planning happened.
  • Materialisation binds to the authorisation. Materializer.materialize(admitted, proposal) takes the AdmissionResult first and matches it to the proposal by fingerprint; a result that authorised something else raises NotAdmitted, and so does a rejected one. Afterwards, a body returning Command(goto=…) is confined to destinations the admitted proposal declared an edge to.
  • Every decision is traced, admitted and rejected alike, as a phase="admission" event carrying the status, fingerprint, checks run and failed codes. The phase is deliberately not "end", so admission decisions cannot inflate the node-execution counts metrics reports.

Three limits, because this is exactly the sort of claim people over-read:

Admission authorises a kind, not its arguments. No rule reaches ProposedNode.args, so a proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone. Materializer drops args by default (forward_args=False); turning that on hands a model's unchecked dictionary to your factory, and gating it becomes the factory's job.

parent_depth is the caller's word. The checker cannot see how deep the run actually is, so a caller that always passes 0 has no recursion limit beyond the nesting visible inside a single proposal.

One surface, one demo registry. grapharc plan drives the loop, and the kinds it plans over come from grapharc/examples/plan_incident.py unless you point --registry module:attr at your own. That module is also where a custom registry declares its STATE_SCHEMA and WRITES; a registry alone is not enough, because a kind nobody declared writes for may write nothing. There is no session-backed or HTTP-backed planning surface yet — ROADMAP.md §12.3.

Configuration, and the zero-config path

Three flags carry every run: --registry (what may be proposed), --policy (what may connect to what), --model. Typing them repeatedly is how people stop using a tool, so they can come from a file:

# grapharc.toml
[grapharc]
registry   = "myco.incident:build_registry"
policy     = "policy.toml"
max_rounds = 6

Resolution is flag > env (GRAPHARC_*) > grapharc.toml > built-in, and every value reports which layer supplied it--json carries a sources block, the human view prints a config line. A config file makes "which policy was I subject to" less visible on the command line, so the provenance is part of the output rather than something a reader reconstructs.

It does not search parent directories. git, npm and cargo all walk upward; this deliberately doesn't. A run must never be silently governed by a policy file in a directory you didn't know about. Read from the working directory, or name one with --config PATH. A relative path inside a config resolves against the config, so the file means the same thing from anywhere.

With nothing configured at all, a run still works. grapharc.stdlib ships general-purpose node kinds — collect_context, investigate, verify, summarize, and apply_change, which is registered and denied by default. No phase anywhere is given run_command. If a model is available and no policy was named, one is generated, written to .grapharc/generated-policy.toml with a REVIEW THIS header, and reported as policy_source: generated. The second run reads it off disk as an ordinary file — so generation is a one-time state, and promoting it to a policy you own is an edit and a mv.

Policy is generated; a registry never is. Policy is data, and the worst case is bad rules you can read. A registry holds functions, so generating one would mean a model writing code that then executes — and the gate would be checking a list the gated thing wrote. The model selects from the shipped kinds instead. Selecting is safe; authoring is not.

The model gateway

The primary backend drives the Claude Code CLI (claude -p), so GraphARC runs on a Claude subscription with no API key. That CLI is a full agent, so the adapter invokes it as a pure inference endpoint: every tool disallowed, no settings sources loaded, no CLAUDE.md pickup, prompt via stdin, flags via an argv array — never a shell string. An injected "run this command" has no tool to run it with.

from grapharc.gateway import get_model

# Subscription, no API key — but text completion only.
worker = get_model("claude-cli/claude-sonnet-5")

# OpenRouter: one key for a catalog spanning most vendors, with
# tool-calling, structured output, streaming, and async.
worker   = get_model("openrouter/anthropic/claude-haiku-4.5")
reviewer = get_model("openrouter/openai/gpt-4o-mini")   # a genuinely different vendor

# Or go direct: your own OpenAI key, or a model on your own machine.
reviewer = get_model("openai/gpt-4o-mini")              # OPENAI_API_KEY
local    = get_model("ollama/llama3.1")                 # no key, no bill, no egress

A spec is backend/model. A mistyped backend with a slash is rejected — openrouterr/x exits 2 naming the known backends. But a bare name with no slash is treated as a model on the claude-cli backend, so --model mock reaches the Claude subscription with a nonsense model name rather than the scripted double; the double needs mock/anything. Worth knowing before you type a bare spec, because that backend spends subscription quota. grapharc models shows what a spec resolves to.

Run an example graph against real models:

grapharc demo stage5 --model openrouter/anthropic/claude-haiku-4.5 \
                    --reviewer-model openrouter/openai/gpt-4o-mini

OpenRouter also carries routing: model-level fallback_models chains, provider order / sort / max_price, and per-call cost in the usage envelope. Every backend reports usage in the same shape, with cached input folded into the total, so a budget meter reads the same fields whichever one produced the turn. openrouter, openai and ollama share a base class — same tool-calling, streaming, retry policy and envelope — and differ only in routing and in where a price comes from.

Retries have a policy, not a loop. Three attempts by default, 0.5s initial backoff doubling to a 20s cap, with jitter drawn from [0.75, 1] — shrinking rather than centred, so successive delays stay strictly increasing rather than merely trending upward. What gets retried is the deliberate part: a timeout, a connection reset, a 429 or a 5xx is transient and re-issued; a 400, 401, 402, 403 or a content refusal is a verdict that will not change and is raised on the first attempt; anything unrecognised is treated as deterministic rather than retried hopefully. A Retry-After header raises the delay but never lowers it, and is itself capped. Streaming is not retried — once a chunk has reached the caller the request cannot be re-issued transparently.

Cost ceilings are enforced, on both sides of a call. A SpendMeter refuses before a call once the ceiling is reached, and after a call it charges the cost and then raises if that crossed the line — so overspend is bounded by the single call that crossed it rather than discovered a node later. The limit worth stating: a call whose cost the provider does not report cannot be charged, and those land in unpriced_calls rather than being guessed at. unpriced_calls > 0 means the ceiling saw less than the whole bill.

Caveats each backend accepts openly. Claude CLI: bind_tools and with_structured_output raise NotImplementedError — the adapter implements neither, so what you get is LangChain's BaseChatModel default, and claude -p in the tool-free mode GraphARC drives it in offers nothing to implement them with. GraphARC also has no cache control on this path — the CLI decides and the usage envelope reports what it did — and calls spend subscription quota. OpenRouter: credit is reserved against max_tokens, so the default is deliberately modest. OpenAI: the API returns token counts and no price, so cost_usd is None and a dollar ceiling counts calls instead of enforcing — pass price_per_million= or price the trace afterwards with observe.cost.RateCard; token budgets are unaffected. Ollama: free by definition, so calls are charged 0.0 rather than counted as unpriced, and whether tool-calling works depends on the model you pulled rather than on the adapter. All of them: the per-call cost_usd is captured, budgeted against, and written onto the trace, so observe.cost reports a recorded figure rather than an estimate whenever the provider gave one. See ROADMAP.md §10.4 for what is still missing (a tenant on the event).

Independent verification

verify_claim is the piece worth copying even if you use none of the rest.

  • The anchor runs before the model. The citation must appear verbatim in the source (whitespace is the only latitude, so a paraphrase is still caught). A fabricated quote is rejected with the reviewer's call count still at zero — a hallucinated citation costs nothing.
  • The reviewer gets a fresh context. If the anchor holds, the reviewer sees only the claim, the quote, and a mechanically extracted window of surrounding source — never the author's conversation. That window is what lets it catch a real quote lifted out of a negated sentence.
  • Ambiguity fails closed. An unparseable reply, a non-boolean supported value, or a citation under 12 characters is a rejection.
  • Independence is enforced in two places, both worth knowing exactly. build_stage5 and build_capstone refuse the same object for author and reviewer — an identity check, which will not catch two separate instances of the same model. The CLI does the stronger check: different_providers() compares the vendor each spec reaches — the model author when the id names one, the backend's own vendor otherwise, so a Claude-CLI author and an Anthropic model over OpenRouter are correctly read as correlated — and grapharc demo --model … --reviewer-model … warns when the pair shares a vendor, because correlated agreement is exactly what the verifier exists to prevent.

Tools and the harness

Inside an agent node: task and context, recall memory, model call, then a permission decision that either denies the call back to the model, routes it to a human, or lets it reach the sandbox; execution is followed by a budget check and an evidence check before any result leaves.

Every diamond above is code, not prompt text. The deny branch is the one to look at: a refused tool never reaches the sandbox and its schema is never shown to the model, so the model is not asked to be well-behaved about a tool it cannot see.

Seven core tools — read_file, write_file, edit_file, list_dir, glob, grep, run_command. grapharc agent <task> registers them into a ToolRegistry that an AgentNode then drives, so the CLI path is the worked example. grapharc/examples/agent_fixit.py is a shipped graph that calls tools, though it defines its own inline rather than using these; the other example stages call no tools at all.

  • Confinement is in the tool, not only the executor. Every path argument is resolved and checked against the workspace by the tool itself, independently of whichever executor is running it — because LocalExecutor confines nothing, and a tool is the last thing standing between a model-supplied ../../.ssh/id_rsa and the file. Both a traversing ../../../etc/passwd and an absolute /etc/passwd raise WorkspaceEscape.
  • Permissions decide which tools run. Deny → ask → allow, first match wins, and an unmatched tool defaults to deny. A denied tool's schema is never shown to the model — it isn't described and then refused, it's invisible.
  • The executor bounds what a tool touches. The default runs the tool in a forked child under a CPython audit hook that confines filesystem paths to the granted workspace plus the interpreter's own runtime paths, refuses sockets unless the tool declared needs_network, refuses subprocess spawning outright (a child would run unhooked and therefore unconfined), and SIGKILLs the whole process group on timeout.

run_command is a decision of a different size from the rest, and is documented as one. It takes an argv list and never a shell string — shell=False always, a single string is refused rather than split, and a pipeline is spelled ["bash", "-lc", "…"] so it stays explicit and visible in the tool-call record. But the child it spawns runs outside every guard in the package: its cwd is workspace-resolved and its environment is an allowlist, and past that it is an ordinary process with your privileges that can reach the whole filesystem. What limits it is the permission policy deciding whether it may run at all, plus whatever real boundary the executor provides. It cannot run under SandboxedExecutor at all — that executor refuses subprocess spawning, so the pairing raises SandboxViolation every time.

What the audit-hook boundary is, precisely: in-process confinement, not a kernel sandbox. An audit hook constrains only the interpreter it is installed in, and its coverage is a maintained list of audit events rather than a guarantee — CPython raises no event for os.stat, so metadata reads outside the workspace are still not blocked. What has changed since this paragraph was first written is the sharper edge it used to describe. The runtime-path exemption is now read-only: reads and mutations use separate grants, so a sandboxed tool trying to drop a .pth file into site-packages — a full escape that would outlive the run — is refused with SandboxViolation and no file appears, while stdlib reads still succeed so imports keep working. Treat the whole thing as defense in depth against a confused tool, not as containment for a hostile one.

Where a real boundary is needed, ContainerExecutor is it. Same run(spec, args) interface, so callers never branch on which executor they hold. It runs each tool call in a throwaway container with one bind mount (the workspace), --network none unless the tool declared needs_network, all capabilities dropped, no-new-privileges, a non-root uid, a read-only rootfs, and memory and pid limits. Its constraints are enforced rather than documented away: the tool must be resolvable inside the image — a lambda, a functools.partial or a bound method is refused before a container starts, and a derived import path that cannot be checked without running host code is refused there instead, contained — and arguments and results must survive JSON. The image is part of the security decision and is yours to choose; the default python:3.12-slim contains no GraphARC and none of your code, so running your own tools means building an image that has them.

Memory

Claims carry provenance — source, observation time, and the run that produced them — and corrections are recorded by supersession rather than overwrite, so a later run can see that a fact was replaced and skip the dead end. Entity resolution is Unicode-aware, so 東京 and 北京 stay distinct instead of both collapsing to an empty key.

It has a disk. SQLiteMemoryStore and SQLiteArtifactStore share one file and are verified durable across genuinely separate processes, not merely across objects. Artifacts are append-only with versions rather than overwrites, provenance is mandatory, and blob content is written before the row that references it — so a crash leaves an unreferenced blob (garbage) and never a row pointing at content that does not exist (a lie).

And it has a graph backend, if the rows are not the shape you want. LadybugMemoryStore implements the same ClaimStore protocol against LadybugDB — an embedded property-graph database with Cypher, forked from Kuzu after Apple closed it. The two SQLite-shaped backends keep claims as rows and rebuild the adjacency in Python on every ClaimIndex; this one stores the edges, so superseded_by is a SUPERSEDED_BY edge and a correction chain is a path you can walk in Cypher rather than a scan you pay for. It buys queryability, and it costs concurrency: LadybugDB takes an exclusive lock on the database, so one process may write it or several may read it, never both at once — sequential hand-off between runs works, concurrent multi-process writing does not. A test spawns a second process against a held database and asserts the failure, so that sentence cannot rot. Install with pip install 'grapharc[ladybug]' — the distribution is real-ladybug; the ladybug name on PyPI is an unrelated building-science package.

Retrieval is real, and its limits are arithmetic rather than adjectives. Okapi BM25F over subject + predicate + object with the subject weighted highest; an optional injected vector channel that stays silent below a similarity floor instead of confidently returning its least bad row; and graph traversal that reads a claim's object as an entity, so a question about A reaches facts about B, each hop decaying the inherited score. Scoring needs corpus statistics, so it is O(claims) per call — nothing here is sublinear and nothing here pretends to be. render_context takes max_tokens, so what reaches a node is budgeted.

Contradiction detection reports; it never resolves. A new claim sharing a normalized (subject, predicate) with a stored one and differing in object is flagged. That is a structural test: it will not relate "is fast" to "is slow", will not match a rephrased object, and will flag a legitimately multi-valued predicate as disagreement. Auto-superseding on a detected conflict would delete half a multi-valued fact inside the one subsystem whose promise is that facts are never destroyed, so a caller supersedes on purpose or not at all.

The limit to know: the in-process store is still the default. grapharc demo stage6 and grapharc demo capstone keep claims in a dict for the life of the process unless you pass --memory PATH, which hands them the SQLiteMemoryStore — verified to survive across two separate interpreters, not just two calls in one. In-process stays the default so a plain run writes nothing you did not ask for. The durable stores that exist are SQLiteMemoryStore (no extra needed) and LadybugMemoryStore (the ladybug extra); there is no extra beyond those.

Sessions, the HTTP API, and policy

Sessions survive a process restart. A SessionManager over a directory keeps status, the event queue, approval holds and the audit trail in SQLite, with graph state in the kernel's checkpointer. Verified by running it: one interpreter created a session, ran two nodes and stopped awaiting_approval holding a gated node; a second interpreter resumed it by id, saw the hold, approved it, and ran the rest — with each node appearing exactly once in an append-only log, so nothing was repeated and nothing skipped. The resuming process must register the graph in its own registry, or it gets UnknownGraphError rather than a guess.

Three things that phrase over-promises if left alone. An interrupt does not stop a running node — it is read at the next superstep boundary, after the whole parallel step has finished and been checkpointed, so a node already inside its body runs to completion. The approval gate is enforced by the session runtime, not by the graph: driving the same compiled graph directly runs gated nodes with nothing holding them. A turn is synchronous — the kernel grew astream while this was being written, and an async turn is buildable and simply not built.

The HTTP API is FastAPI plus SSE — create a session, list, get, post an event, stream the trace, fetch it as NDJSON, healthz. A request may name a registered graph and supply input and a budget; it may not describe a graph, because topology comes from a registry the operator fills in Python. But note the seam: it does not use the session layer above. It ships its own in-process runtime whose sessions die with the process, never evict, and record message and approval events without delivering them into a running graph. Two session layers that have not been joined (ROADMAP.md §12.3).

Policy is a TOML document over nodes, edges, tools and spend, with tiered evaluation — every deny before every ask before every allow, so a broad deny beats a narrow allow including one scoped to a single tenant. Every decision lands in an audit record naming the rule id, the reason, the policy version and a digest of the document, so a decision can be tied to the exact text that made it. And the seam, now narrowed to exactly half: the edge half is wired and the tool half is not. PolicyEngine.edge_policy() compiles the document into the EdgePolicy the admission checker consults, and grapharc plan --policy is a real caller — so what may connect to what is governed by a document you can read. But permission_policy(), check_tool() and approval_router() have no caller outside grapharc/policy/, so grapharc agent still assembles its tool gating from --allow / --deny / --ask globs. The most dangerous surface in the package is the one the document cannot reach yet; issue #6 is that work, and the precedence question it has to settle is what happens when a flag allow meets a document deny.

Reading a run afterwards

trace is the only writer. metrics, replay, diff, cost and the OTel exporter all read that one file and nothing else, which is what keeps a dashboard from contradicting an audit trail.

grapharc replay trace.jsonl <run-id>          # node sequence, folded state, timing
grapharc diff   trace.jsonl <run-a> <run-b>   # what changed between two runs
grapharc metrics trace.jsonl <run-id>         # tokens, retries, termination reason

Replay is a reconstruction, not a re-execution. It rebuilds the node sequence, the folded state, the timing and the failures from the JSONL and calls no model, no tool and no node. Two limits come from the recording side rather than this one, and both are in the signature rather than a comment: strings past 2,000 characters were truncated when they were written, so they replay truncated; and the trace does not record which state fields have reducers, so a field LangGraph appended to replays last-write-wins unless you pass the reducer.

Spans are optional by construction. One root span per run, one child per node execution, with AgentNode sub-steps parented by inference — and a sub-step whose parent cannot be identified is parented to the run span rather than to a guess. The OpenTelemetry dependency is confined behind a Protocol, so importing the module needs no OTel installed. This was carried as unverified against the real SDK for a while; it has now been run against opentelemetry-sdk 1.44.0 with spans arriving at a real exporter.

Cost attribution is per run, thread and node, and it distinguishes what was measured from what was guessed. Tokens are counted from the same events metrics uses — node end events plus work that happened outside any node span, which is what a grapharc agent run is entirely made of — and the suite asserts the two agree, so a cost report and an audit trail cannot drift apart. The provider's own cost_usd is written onto the trace, so recorded_cost_usd holds a real figure when the backend reported one; a backend that reports none falls back to tokens priced against a RateCard you supply, and the two never mix. There is still no tenant on a trace event, so per-tenant attribution is not offered rather than being approximated.

Tests are gates

Each stage ships a failure-gate test, not just a happy path:

  • Stage 0 — an injected failure between the temp write and the rename leaves no report at all; resuming the same thread from its checkpoint produces exactly one, with no orphaned temp file, and both attempts are on the trace with non-overlapping step numbers. (The failure is an in-process RuntimeError, not a killed process — write atomicity and checkpoint resume are what this actually tests.)
  • Stage 1 — an impossible task halts on a no-progress window within three rounds of a hundred-iteration ceiling, with no_progress recorded; a round cap is a second, independent brake.
  • Stage 2 — a bad model output is attributable to the exact node, step and state delta, and the checkpoint history holds a replay point from just before it was caught. Three rounds of prose, including a literal ROUTE TO: all_verified injection attempt, cannot move the router: the run ends on the deterministic attempt cap with nothing verified.
  • Stage 3 — one crashed worker and one hung worker out of three still produce an answer, with both failures recorded by cause; overlapping shards manufacture duplicate evidence and unique_sources still counts chunks rather than repetitions.
  • Stage 4 — an impossible investigation halts on a no-progress window, far under the hard ceiling.
  • Stage 5 — a fabricated citation is rejected with the reviewer never called; a quote mined from a negated sentence reaches the reviewer with the surrounding context that exposes it; unparseable replies, non-boolean verdicts and trivial citations all fail closed; the reviewer's prompt is checked to contain the evidence window and not the author's conversation.
  • Harness — a tool cannot read a file, list a directory, delete a file or remove a directory outside its workspace, cannot open a socket without declaring the capability, and cannot spawn a subprocess; a sibling directory whose name merely starts with the workspace path is not treated as inside it; a tool cannot plant a .pth in site-packages while stdlib reads still work; a hung tool and a tool that installs a SIGTERM handler and keeps working are both killed for real.
  • Stage 6 — three runs against a shared store: a later run reuses an earlier fact, sees what superseded it, and is shown the dead end with its provenance.
  • Capstone — a reviewer that rejects everything yields no answer and zero memory writes.
  • Admission — renaming a denied kind does not evade the policy, and neither does hiding the rename in a nested scope; naming an instance after a permitted kind does not borrow that kind's permission; a live node listed without a kind cannot be wired at all rather than being waved through; an already-overspent run admits nothing that costs; every failed check is reported, not just the first; and admission events do not pollute the run metrics.
  • Sessions — a second runner cannot claim a running session; a decision naming a different request does not release the hold; approving one held node does not release the others.
  • Server — a sink exception cannot fail the node that recorded the event; a rejected create leaves no session and no trace directory; the SSE stream and the trace file are the same record, including for an answer over 2,000 characters; a state value JSON cannot hold appears on both views.
  • Container — tests that need a real runtime skip themselves when no runtime or image is present, and never pull one.
uv run pytest          # scripted models: deterministic and free
uv run pytest -m live  # real backends: spends money and quota

Live tests are deselected by default via addopts in pyproject.toml, so a plain pytest never reaches a real model — verified: a plain run reports 10 deselected. --strict-markers is on, and a misspelled marker is a collection error rather than a test that silently spends money.

Status and limits

Re-derived on 2026-07-28 by running each item, not by reading the commit log.

Distribution

  • 0.1.0 on PyPI reports the wrong __version__. The published wheel's metadata says 0.1.0pip show and the project page agree — but the module inside it still carries __version__ = "0.1.0a0", because it was built from a tree where only pyproject.toml had been bumped. PyPI releases are immutable, so 0.1.0 cannot be corrected in place; the fix ships in 0.1.1. pip install grapharc works and grapharc demo stage0 runs — this affects the version string alone.
  • Fixed: the source is on the public remote now, so the documented git clone && uv sync path works. It was the ship-blocker for most of this project's life.
  • Fixed: the package is on PyPI, so pip install grapharc works. Verified in a clean virtualenv: bare install, import, and grapharc demo stage0.

Built and unreachable — this used to be the honest headline, four subsystems deep. One seam is left.

  • The HTTP API does not use the durable session layer. It has its own InProcessRuntime, whose sessions die with the process and whose approvals are recorded without being delivered. ROADMAP.md §12.3.
  • Closed: grapharc plan drives the governed loop; PolicyEngine.edge_policy() compiles the TOML document into the gate AdmissionChecker consults, and grapharc plan --policy is the caller; grapharc demo --memory PATH hands the shipped graphs the durable SQLite store.
  • Closed: the shipped registry withheld the trace recorder from its PlannerNode and Materializer, so grapharc plan wrote a file with no plan event and no start/end pair for any node it executed — the paragraph above claiming otherwise was true of a hand-wired loop and false of the one the command drives. Both now get the recorder, and a test asserts the phase counts.

Real limits of things that do work

  • Admission authorises a kind, not its arguments. A proposal carrying args={"path": "/etc/passwd"} is admitted on the strength of its kind alone.
  • The audit-hook sandbox is in-process confinement, not a kernel boundary. os.stat outside the workspace is not blocked, because CPython raises no event for it. ContainerExecutor is the boundary where one is needed.
  • run_command is not confined. Argv-only and never a shell, but the child is an ordinary process with your privileges.
  • Closed: a max_seconds past the platform's time_tfloat("inf"), or a plausible "effectively unlimited" like 1e10 — used to disable the deadline guard for the rest of the process. setitimer raised after the SIGALRM handler was installed and the process-wide slot taken, leaking both, so every later run silently fell back to the mechanism that cannot unwind a blocking syscall: a 0.3s ceiling then took a 5s sleep to notice. Arming is undone on failure now, and the armed delay is clamped to what both mechanisms accept.
  • Closed: every async def node double-charged its token re-reports. The re-report ledger was keyed by thread ident, but on_llm_end is sync — under ainvoke LangChain dispatches it to a worker thread while the body stays on the event loop — so the automatic charge found no ledger and the node's named re-report was charged again. Any node using the shipped charge_usage, AgentNode._charge_tokens or planner.proposal._charge reported double its real spend and hit max_tokens at half its declared allowance. The ledger is a contextvars scope now, which also fixes an inner scope discarding the enclosing node's.
  • Closed: a bracket anywhere in a model's prose hijacked JSON extraction, because only the first {/[ was ever tried. Based on the context [lines 3-5]: {…} was rejected as unparseable, and — worse — Analysis (note [1]): {"supported": false} returned a perfectly valid [1], substituting a fabricated value for the verifier's actual answer. Every opener is tried now, and length alone turned out not to be a safe rank — a citation list like [101, 205, 309, …] longer than the verdict still won — so object spans are tried before array spans, each longest-first; junk still returns None, so fail-closed is unchanged.
  • interrupt() suspends but cannot be resumed. LangGraph's native interrupt stops the graph and shows on get_state, and there is no supported resume path — resuming means passing a Command as input, which is closed by design. Use the session layer's approval gate for human-in-the-loop.
  • Still unwrapped from LangGraph: retry_policy, cache_policy, durability, subgraphs. .inner reaches them, but execution entry points there fail closed, so .inner is an inspection escape hatch and not a way to run the graph.
  • Cost is recorded when a backend reports one, estimated when it does not. Both gateways publish the provider's cost_usd through the same llm_output envelope, the runtime's usage callback writes it onto the node's end event, and an agent's model events carry the per-call breakdown. A backend that reports no price still falls back to a RateCard estimate, and the two figures stay apart — recorded_cost_usd is never a guess. Still missing: no tenant on a trace event, so per-tenant attribution is not offered.
  • A node's tokens are its own, not the run's movement while it ran. Worth stating because it was the other way round: an end event carried the difference between two readings of the run's shared meter, so under fan-out the workers' windows overlapped and each was credited with its siblings' concurrent spend. Three workers costing 8 tokens each traced as 24/16/8, and metrics and cost agreed on 48 for 24 tokens of real work — doubling the estimated bill purely because the work ran in parallel. Attribution now comes from a per-node scope on the meter, so the same work costs the same serially and in parallel; a hand charge the usage callback never saw still lands on the node that made it.
  • A planning round is an envelope, not a measurement. A round event used to carry the planner's tokens and the round's duration_ms, both of which metrics, cost and replay add on top of node totals — and the planner's spend was already reported by its own plan event, so it was counted twice, and a round's duration encloses the plan plus every node it ran. Neither is on the event now; both are on its state_delta as round_tokens / round_iterations / round_duration_ms, where no reader sums them. RoundRecord.iterations also holds a figure now rather than always 0.
  • The Claude CLI backend is completion-only. Tool calling and structured output need one of the OpenAI-wire backends: openrouter, openai, or a local ollama.
  • A session turn is synchronous, and a runner claim is a claim rather than a lease — nothing reclaims a session whose runner died holding it.
  • A bare model spec resolves to the paid claude-cli backend. --model mock does not reach the scripted double; it becomes the model name mock on the subscription backend. Only the slash form (mock/anything) reaches the double. A mistyped backend with a slash is rejected properly, exit 2.
  • .env is found by walking up parent directories; grapharc.toml is not. The config layer refuses an upward search on purpose — a run must not be governed by a file you did not know about. The credential loader predates that decision and still searches upward, so the thing that spends money is discovered more eagerly than the thing that constrains it.
  • grapharc run has no budget unless you give it one. Set any of --max-tokens, --max-iterations, --max-seconds, or --max-concurrency; without them each dimension is unlimited and the gate admits a topology of any worst-case cost.

Verified this pass: pytest → 1,754 passed, 12 deselected (the live ones); ruff check . clean; all eight grapharc demo stages green, plus the trace / metrics / viz / replay tour against a freshly recorded demo trace; the wheel builds and imports all 116 submodules in a clean virtualenv with [all]. The test count is a snapshot, not a property of the project — pytest re-derives it in one command, which is the only reason it is quoted.

ROADMAP.md tracks what is built and what is not, item by item.

Design lineage

Architecturally inspired by systems studied from public documentation: OpenClaw (policy-before-schema tool gating, file-first state, and its security post-mortems), Hermes Agent (budgeted tiered memory, ephemeral subagents), Claude Code (advisory-vs-enforced split, subagent context isolation, verification-centered loops), and OpenRouter (routing semantics, budget-scoped accounting).

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

grapharc-0.1.2.tar.gz (2.5 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

grapharc-0.1.2-py3-none-any.whl (425.5 kB view details)

Uploaded Python 3

File details

Details for the file grapharc-0.1.2.tar.gz.

File metadata

  • Download URL: grapharc-0.1.2.tar.gz
  • Upload date:
  • Size: 2.5 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for grapharc-0.1.2.tar.gz
Algorithm Hash digest
SHA256 d1768f32dd03d048cdcfec09ed722a628539299917c28b5d9fdede04ddb67e17
MD5 4fa4bf7a38f550b691885dedc1bbad65
BLAKE2b-256 0ac6024dd153298576122e89da60b4488486ccbbcfa24b52774c1af8f7598cf4

See more details on using hashes here.

File details

Details for the file grapharc-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: grapharc-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 425.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for grapharc-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 2501506e57cef433ecde23f74d56609dbf4f5bea0b3d133504f31a87b3070a2a
MD5 4f660218c7823a8be1892635ca3ffd2b
BLAKE2b-256 eacfe1651c35e34e5d8e7a10d722661db6bc5c00118482f9a658f74064982ab8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page