Architecture

July 22, 2026 · View on GitHub

Canonical reference for ChainWeaver's architectural intent, major boundaries, and design decisions. Consult this before scoping changes or choosing where new code belongs.


Architectural intent

ChainWeaver is a deterministic graph runner. It compiles ordered sequences of tool invocations into flows and executes them with strict schema validation at every boundary. No LLM, no network I/O, no randomness enters the executor.

The entire value proposition rests on this determinism: given the same input and tools, the same flow produces the same output every time.


Module boundaries

ModuleResponsibilityKey constraint
builder.pyFlowBuilder: chainable API that produces validated Flow objectsPure construction sugar — no execution logic; delegates to Flow/FlowStep
compat.pyschema_fingerprint(), CompatibilityIssue, check_flow_compatibility()Pure utility; no execution or I/O
compiler.pycompile_flow(): static schema flow validation pre-executionReturns CompilationResult; no execution logic
contracts.pyToolSafetyContract, SideEffectLevel, StabilityLevel, DeterminismLevel, merge_safety(), evaluate_predicate() — determinism + operational safety vocabulary (#19, #125, #293, #9, #8)Pure module: enums + frozen Pydantic model + AST-based predicate evaluator. Safety metadata is descriptive; enforcement belongs to a host/policy surface such as FlowServer.
decorators.py@tool decorator for zero-boilerplate tool definitionReturns a Tool subclass; introspects type hints
tools.pyDefine Tool: name + callable + Pydantic I/O schemas + schema_hash + safety contract (#19); Tool.from_flow() adapter (#24) with merge_safety derivation (#125)Tool functions must be fn(BaseModel) -> dict[str, Any]; from_flow reuses the same contract — its closure dispatches FlowExecutor.execute_flow and surfaces inner failures as FlowExecutionError
flow/__init__.pyRe-export the stable chainweaver.flow surfaceFacade only; preserves historical module-qualified and pickle references.
flow/definitions.pyDefine Flow, FlowStatus, ConditionalEdge (#9), and ContextCollisionPolicyCore linear-flow definitions; operational FlowStatus stays separate from governance lifecycle.
flow/steps.pyDefine FlowStep and RetryPolicyShared by linear and DAG flows; no topology logic.
flow/dag.pyDefine DAGFlowStep, DAGFlow, and validate_dag_topologyDAG-only models, structural determinism_level inference, and topology validation.
flow/governance.pyDefine FlowLifecycle and FlowGovernanceReview/promotion lifecycle only; never controls executor admission.
flow/drift.pyDefine DriftInfoImmutable drift record only.
flow/refs.pyResolve schema/exception class refs and enforce the opt-in module allowlist policy (#345)Imports referenced modules only after the active policy permits them.
step_index.pyNamed sentinels for synthetic flow input/output validation records (#339)Pure helper module; keeps validation-record sentinel values out of executor call sites.
registry.pyStore and retrieve Flow/DAGFlow by (name, version); status filtering; multi-version supportDelegates persistence to a RegistryStore; defaults to InMemoryStore
storage.pyRegistryStore Protocol + InMemoryStore (default) + FileStore (one JSON file per flow)Filenames are {name}@{version}.flow.json; concurrent multi-process access not coordinated
analyzer.pyChainAnalyzer: offline schema-compatibility analysis — compatibility matrix, chain enumeration, suggested flows (#77)Pure static pass: no LLM, no network, no randomness; cycle-free DFS bounded by max_depth
decisions.pyDecisionCallback Protocol + DecisionContext + coerce_decision_callback (#102)Pure protocol module — no executor logic, no network, no randomness; the executor depends on it but it does not depend on the executor
executor.pyRun flows step-by-step (linear) or level-by-level (DAG), validate I/O, merge context, drift detection, invoke DecisionCallback at decision points, dispatch capability steps via the _execute_capability_step hook. execute_flow_async provides the async lane (#80) with opt-in DAG-level concurrency (max_step_concurrency, #344). Supports concurrent calls per the concurrency contract (#336).No LLM, no network I/O, no randomness — mechanically enforced by tests/test_executor_import_contract.py (#354). No agent-kernel / weaver-spec / contextweaver imports — those live in integrations/ and reach the executor only via the DecisionCallback seam and KernelBackedExecutor subclass hook
_execution/Internal, no-I/O execution collaborators shared by both lanes (#330, #331): context.merge_step_outputs is the single context-merge honouring on_context_collision (#337).Same three invariants as executor.py; covered by the import-contract check (#354). Private package — nothing here is in top-level __all__.
integrations/weaver_spec.pyRe-exports the weaver-contracts types (SelectableItem, RoutingDecision, CapabilityToken, …); flow_to_selectable_item() exporter; routing resolvers (make_routing_decision, selected_capability_id, resolve_flow_from_routing_decision); WEAVER_SPEC_VERSION (#91, #107, #233)Consumes the published weaver-contracts package behind the [weaver-stack] extra — guarded import raises a clear ImportError without it
integrations/contextweaver.pyRoutingDecisionAdapter (DecisionCallback impl) + ContextweaverClient Protocol + StaticRoutingClient (#106)Translates RoutingDecision → tool name; no hard dep on a contextweaver SDK
integrations/agent_kernel.pyKernelBackedExecutor (FlowExecutor subclass) + KernelProtocol + InMemoryKernel (#89)Overrides only _execute_capability_step; no LLM, no randomness; kernel side-effects are the kernel's responsibility
mcp/MCP integration package (#70, #72, #150, #259, #294): MCPToolAdapter (inbound), metadata-aware FlowServer (outbound), JSON Schema ↔ Pydantic bridge.Default outbound exposure requires active lifecycle plus known read-only, approval-free safety. Explicit flow_names is the operator override.
exceptions.pyTyped exception hierarchyAll inherit ChainWeaverError; carry context attrs
log_utils.pyPer-step structured loggingLibrary-safe (NullHandler only); no handler config
cost.pyCostProfile + CostReport for cost-avoided estimationPure data + a single compute_cost_report helper; no execution logic
observation.pyTraceRecorder + ObservedTrace for ad-hoc tool sequence captureIn-memory storage only; persistence deferred
observer.pyChainObserver + FlowSuggestion: record runtime tool calls, mine repeated contiguous sub-sequences, propose flows (#78)Pure n-gram counting: no LLM, no network, no randomness; suggestions are proposals, never auto-registered
lessons.pytrace_to_lesson_candidate() + LessonCandidate / LessonEvidenceStep / LessonReview: normalise an ExecutionResult into a reviewable, workflow-scoped lesson candidate for the Weaver Stack's lessonweaver (#210)Pure projection: no LLM, no network, no randomness; no hard dependency on lessonweaver (emits neutral Pydantic data); identifies the failure point but never asserts the lesson outcome; banned from executor.py
service.pyChainWeaverService + ServiceConfig + ServiceMetrics + ServiceEvent: continuous analyze→observe→propose→govern loop (#101)Ties analyzer + observer + an in-service proposal gate; LLM hooks opt-in; full GovernanceManager (#13) integration deferred
viz.pyflow_to_ascii, flow_to_mermaid, result_to_mermaid pure renderersNo external dependencies — string generation only
serialization.pyYAML + JSON encode/decode for Flow and DAGFlow (flow_to_json, flow_from_yaml, etc.)JSON path is dep-free; YAML requires pyyaml (optional extra chainweaver[yaml]); schema/exception refs round-trip as "module:qualname" strings
cli.pytyper-based CLI hosting the full command surface: inspect, viz, validate, check, run, profile, diff, attest, suggest, dump-schema, doctorinspect / viz read from a process-scoped default registry installed via cli.set_default_registry; the file-oriented commands (validate, check, run, profile, diff, attest, suggest, doctor) read directly from disk
testing/Public test harness for flows (FlowTestRunner, fake_tool, capture_steps, assert_result_matches, record_then_replay) (#132, #153)Hooks at the Tool.fn boundary — never edits executor.py. Helpers are imported as from chainweaver.testing import ..., mirroring the integrations.opentelemetry pattern; not re-exported from top-level __all__ (except FixtureStaleError, which follows the error-catalog convention)
pytest_chainweaver (top-level)Pytest plugin registered via pytest11: flow_runner / flow_runner_session fixtures + @pytest.mark.flow(...) markerLives outside the chainweaver/ package so pytest-cov starts coverage before the library is imported (see Design traps below)
__init__.pyPublic API surfaceEvery public symbol must be in __all__

Decision context

DecisionRationale
Sequential-only execution for linear FlowPhase 1 MVP. Unchanged.
DAG execution for DAGFlowPhase 2: topological level grouping. Parallel/async execution for independent levels is planned for v0.2.
Pydantic for all schemasDeterministic I/O contracts between steps.
No LLM calls in executor"Compiled, not interpreted."
from __future__ import annotationsForward-reference support; cleaner type hints.
Pydantic BaseModel for StepRecord/ExecutionResult (since #20)Errors are stored as error_type / error_message strings instead of live Exception instances, so the trace round-trips through JSON.
step_type + capability_id on DAGFlowStepPer-step capability slot for Weaver Stack kernel integration (weaver-spec I-07). "capability"-typed steps are dispatched by KernelBackedExecutor._execute_capability_step (#89); the base FlowExecutor rejects them.
Flow.capability_id and DAGFlow.capability_id (#90)Flow-level capability identifier — distinct from DAGFlowStep.capability_id. Names the flow itself as a routable capability; resolved by flow_to_selectable_item() for contextweaver catalog ingestion.
FlowStatus vs FlowLifecycle (#268)FlowStatus is the executor's operational gate (active / needs-review / disabled). FlowLifecycle is the macro-flow review path (observed / suggested / draft / reviewed / active / ignored / archived). Never overload one for the other.
DecisionCallback Protocol in chainweaver/decisions.py (#102)Single LLM-router / contextual-narrowing extension point. Executor invokes it for steps with decision_candidates set; failures wrap as DecisionCallbackError and abort the step (no silent fall-through to tool_name).
Consume weaver-contracts behind an optional extra (#91, #107, #233)The weaver-spec contract ships on PyPI as weaver-contracts. ChainWeaver consumes its I-03 / I-04 / I-07 dataclasses directly via the optional [weaver-stack] extra rather than carrying mirror types — chainweaver.integrations.weaver_spec (and the contextweaver / agent_kernel adapters that import it) guard the import and raise a clear ImportError when the extra is absent, so the base install stays dependency-light.
KernelBackedExecutor as a subclass, not a flag (#89)Subclass overrides only the _execute_capability_step hook. Keeps the executor's three invariants (no LLM, no network I/O, no randomness in executor.py) intact — kernel side-effects live in integrations/agent_kernel.py.
Concurrency contract: run-scoped state off the instance (#336)One FlowExecutor supports concurrent runs. Run-scoped state (the stream_flow event collector, active_flow_version, replay/resume markers, injected dynamic_params) lives in a per-instance contextvars.ContextVar; each entry point (_run_scope) binds a fresh per-scope copy, so concurrent runs never cross-talk — including concurrent execute_flow_async tasks on one event-loop thread, which a threading.local would not isolate. Mutating ops (register_tool, add_middleware, accept_drift) are setup-phase-only.
State transitions are copy-on-write (#335)accept_drift / set_flow_status route through FlowRegistry.update_flow_state (model_copy + persist) instead of mutating a registry-shared Flow in place, so other holders of the object are never silently altered.
Cycle detection at registration timeFail fast — no silent deferral to execution. Belt-and-suspenders check also runs in the executor for flows created without registry.
Branch targets must be direct dependents (#9)Keeps conditional routing local — a ConditionalEdge.target_step_id (or default_next) must reference a step that already lists the branching step in its depends_on. This makes "skipped" propagation a one-hop computation in the executor and prevents branches from jumping across unrelated subgraphs. Enforced at registration time by validate_dag_topology.
AST-based predicate evaluator (#9)Predicate strings are parsed with ast.parse(mode="eval") and walked against an explicit node allow-list — eval/exec are never called. The grammar deliberately excludes attribute access, function calls, and binary arithmetic so predicates stay routing decisions, not computations (unary +/- is permitted so signed literals like n == -1 parse).

Design traps

Things that look wrong but are intentional. Do not "fix" these without a solution for the underlying constraint.

StepRecord and ExecutionResult errors are stored as strings

These types are Pydantic models (since #20). The previous design trap — using dataclass because Pydantic could not serialize an Exception — was resolved by replacing the live error: Exception | None field with two string fields: error_type: str | None (the exception class name) and error_message: str | None (the formatted message). This keeps the entire execution trace JSON-serializable end-to-end.

Do not add a live Exception field back to either model; the error_type / error_message pair is the contract.

log_utils.py, not logging.py

Renamed from logging.py (commit ccfe7f8) to avoid shadowing Python's logging stdlib module. Do not rename it back.

tests/helpers.py is separate from tests/conftest.py

Extracted intentionally (commit 7ef3245). Boundary:

  • helpers.py → shared Pydantic schemas and tool functions (importable by any test)
  • conftest.py → pytest fixtures that compose objects from helpers.py

Do not merge them back together.

pytest_chainweaver.py lives at the repo root, not under chainweaver/

The pytest11 entry-point registered in pyproject.toml points at the top-level pytest_chainweaver module rather than at chainweaver.testing.plugin (#132). This is deliberate: pytest loads pytest11 plugins by importing the entry-point module, and any module under chainweaver.* transitively triggers chainweaver/__init__.py, which eagerly imports the entire library. If that import cascade happens before pytest_cov.plugin can start coverage tracking, every import-time statement in the package is counted as "missed" — coverage collapses from ~94 % to ~64 %.

Keeping the plugin out of the chainweaver namespace breaks that chain: pytest's entry-point loader touches only pytest_chainweaver plus pytest itself, and the heavy chainweaver imports happen lazily inside the fixture bodies. The module is included in the wheel via [tool.setuptools] py-modules = ["pytest_chainweaver"].

Do not move the plugin under chainweaver/testing/ to "tidy up" the layout.

chainweaver.testing helpers are NOT in top-level __all__

Like chainweaver.integrations.opentelemetry, the test-harness symbols (FlowTestRunner, fake_tool, etc.) are exported from the subpackage chainweaver.testing and intentionally absent from chainweaver/__init__.py __all__. This keeps the public-API snapshot focused on runtime symbols and lets test helpers evolve without churning the snapshot fixture.

record_then_replay monkey-patches Tool._call_fn / _call_fn_async, not Tool.run

The decorator swaps Tool._call_fn (the boundary between Pydantic validation and the user-supplied callable) — not Tool.run — so input and output schema validation still execute during replay. A stale fixture with a now-invalid output payload therefore fails the output validator loudly, the same way a real Tool.fn returning bad data would.

Both Tool._call_fn and Tool._call_fn_async are patched so the synchronous lane (FlowExecutor.execute_flow) and the async lane (FlowExecutor.execute_flow_async) are covered. The two must record each logical call exactly once, but an async tool reached through the sync lane enters _call_fn, which bridges to _call_fn_async via asyncio.run — so both patched methods sit in the call stack for that one call. The sync patch therefore records/replays only sync tools and passes async tools straight through to the async patch, which is the single record/replay point for them. (Sync tools never reach _call_fn_async; async-lane calls never reach _call_fn.)

Both methods are class-level monkey-patched and restored in a finally block. This is intentional: a context-local variable would require threading a session handle through every Tool invocation, which defeats the decorator's drop-in shape. The trade-off: concurrent record/replay sessions (e.g. under pytest-xdist) are not supported.


Planned modules

The following module names are reserved for planned features. Do not create files that conflict with these names:

Reserved nameIssuePurpose
analyzer.py#77 ✅Offline schema-compatibility analyzer (delivered)
decisions.py#102 ✅DecisionCallback Protocol (delivered)
observer.py#78 ✅Runtime flow observer + flow suggestion (delivered)
viz.py#79 ✅Flow visualization (delivered)
cli.py#44 ✅CLI interface (delivered)
schemas.py#135 ✅JSON Schema export for flow files (delivered)
mcp/#70, #72, #150 ✅MCP adapter + flow server (delivered; requires chainweaver[mcp])
integrations/weaver_spec.py#91, #107, #233 ✅Consumes weaver-contracts; SelectableItem exporter + routing resolvers (delivered)
integrations/contextweaver.py#106 ✅RoutingDecisionAdapter (delivered)
integrations/agent_kernel.py#89 ✅KernelBackedExecutor (delivered)
integrations/langchain.py / integrations/llamaindex.py#82 ✅LangChain / LlamaIndex bridge adapters (delivered)
export/#25 ✅Flow export formats (delivered)
governance.py#13Governance policies

Weaver Stack guardrail (still in force after #89/#90/#91/#102/#106/#107)

KernelBackedExecutor (#89) is a separate subclass of FlowExecutor — do not add agent-kernel, weaver-spec, or contextweaver imports to executor.py itself. The core FlowExecutor stays deterministic and standalone; the only seams that reach into integrations are:

  • The DecisionCallback Protocol from chainweaver.decisions (a protocol — no third-party imports leak through it).
  • The _execute_capability_step hook — overridden by KernelBackedExecutor, not by the base class.

docs/SPEC_COMPAT.md declares the supported weaver-spec range; changing it follows the procedure documented there.