This document describes what the graph engine does today, what is wired as a seam for a later phase, and where a deploying engineer needs to provide their own binding.
A graph is a YAML or JSON manifest (api_version: "bounded-loops.dev/graph/v1").
The compiler validates and rejects:
- Unknown fields at every node and slot (closed schema — no extra keys allowed).
- Absolute paths and secret-shaped field names (
api_key,token,password, etc.) anywhere in the graph. - Node kinds whose provider is baked in (
provider_in_sloterror — connection slots declare capabilities, never provider names). - Cycles in the edge graph.
- References to undeclared slots, ports, or nodes.
Node kinds: research_claim, research_source, audit, tool, loop, subgraph,
router, join, approval, publish.
Isolation levels: workspace_only, process_restricted, container_restricted,
customer_managed_worker.
Effects: read_only, workspace_write, external_write, financial, irreversible.
Data classes: public, internal, confidential, restricted.
The compiler enforces that an admitted connection's capabilities, allowed effects, isolation rank, and data-class rank all satisfy the node's declared requirements. A node that no admitted connection satisfies fails closed at compile time — never at run time.
CLI:
bl graph lint manifest.yaml # validate only
bl graph plan manifest.yaml --connections connections.json # compile to planbl graph run --execute --out <dir> (no manifest) runs the built-in sandbox probe
node inside a native OS sandbox:
- macOS: Seatbelt (
sandbox-exec) - Linux: bubblewrap (
bwrap)
No Docker daemon, no root, no container registry. The sandbox probe attempts a network connection and an out-of-workspace write; an independent gate then reads the artifact and PASSES only if the OS actually denied both. The node never grades itself.
Platform detection is automatic via probe_platform(). If neither sandbox mechanism
is available, the command reports that honestly rather than silently skipping isolation.
bl graph run --execute manifest.yaml --connections connections.json --inputs inputs.json --out <dir>
runs graphs whose nodes bind an admitted local_cli connection.
The connector invokes the user's own subscription-mode agent CLI as a subprocess:
provider_id in connections.json |
CLI invoked | Prompt delivery |
|---|---|---|
claude |
claude -p |
stdin |
codex |
codex exec --skip-git-repo-check |
positional arg |
grok |
grok -p |
positional arg |
muse |
muse exec |
positional arg |
agy |
agy -p |
positional arg |
The engine never reads, stores, or logs the user's credentials. The CLI
authenticates out-of-band via its own login session. Network access for admitted
local_cli nodes is resolved via the configurable egress posture (see item 11):
OPEN by default (no cage), or ALLOWLIST (macOS Seatbelt cage, opt-in). An admitted
https node gets its own network mode (ALLOWLIST, isolation lifted to
container_restricted — see item 8). Any node that is neither local_cli nor
https is refused at preflight and never reaches a network-mode decision.
The run-time prompt (inputs.json: node_id -> prompt string) is not persisted in
the run directory. A prompt may contain a secret; the content-addressed reply
artifact is the durable receipt.
Conditional edges (when) — an edge fires only when its SOURCE node reached the
outcome the edge names. Four values, and nothing else:
when |
The edge applies when its source | Use |
|---|---|---|
null (default) |
SUCCEEDED | ordinary data dependency; identical to pre-0.5 behaviour |
succeeded |
SUCCEEDED | the same rule, stated explicitly |
failed |
FAILED | route around a failure — cleanup, notify, fall back |
skipped |
SKIPPED | distinguish "upstream failed" from "upstream never ran" |
terminal |
any terminal state | a branch that must run whatever happened |
failed means FAILED and nothing else. A run-level stop — a fail-closed halt, an operator
cancel, an expired deadline — satisfies neither succeeded nor failed, so a recovery
branch does not fire on one and the halt propagates. Use terminal if you want a
branch to run on those too.
Two rules decide admission, and the distinction between them matters:
- A condition only ever removes an edge from consideration. It never overrides the
join.
all_selectedstill tolerates a failed parent andany_successfulstill admits as soon as one parent succeeds — those are the join's decisions, not the condition's. - An unconditional edge whose dependency failed still blocks. Only an edge you explicitly conditioned can be excluded. This is what keeps a failed dependency from becoming a green light when a node has several parents.
A node whose every incoming edge was excluded is marked SKIPPED — its branch was not
taken — and the skip cascades to the rest of that branch. A run ends SUCCEEDED when every
node either succeeded or was skipped; a FAILED node still fails the run, even if a
failed-conditioned branch handled it.
Data-dependent conditions (result.status == 'failed') are not supported: anything
outside the five values above is refused when the graph is validated. Versions up to 0.4.0
accepted such strings and then ignored them, so those edges never applied their condition.
failed, skipped and terminal require fail_mode: continue_declared. Under
fail_mode: fail_closed (the default) the run stops at the first node failure, so the
scheduler never gets another turn and a failure-conditioned edge could never be admitted.
Authoring one there is refused at validation rather than accepted and silently ignored,
and the error names the mode to switch to.
continue_declared keeps driving the graph after a node fails — but only past the node's
own bounded-loop outcome: a gate rejection, a worker fault, an unverified artifact, a spent
retry budget, exhausted re-drives. A broken gate, a denied execution policy or isolation
refusal, a missing worker, a rejected or unresolved human approval, an exhausted spend cap, a
broken worker contract, or an unmeasurable budget all still stop the run whatever the mode:
continuing past those would keep spending money, trust a gate that has already proved
unreliable, or route around a control that said no.
An unconditional edge whose dependency failed still blocks in either mode. A run that had any
FAILED node still reports FAILED, even when a failed-conditioned branch handled it —
whether a handled failure clears the run is a repair-semantics question, not this one.
The mode is recorded in the run directory's run-meta.json, so resume and approve drive
the graph exactly the way the original run did. A run directory written before the mode was
recorded reduces to fail_closed, which is how it originally executed.
Repair a node upstream (on_failure: repair) — a conditional edge points forward; a
repair points backward. When a node exhausts its budget, the ancestor it names runs
again, and everything reachable from that ancestor runs again after it.
policies: {fail_mode: continue_declared, repair_budget: 2}
nodes:
- id: verify
on_failure: {mode: repair, target: fetch} # fetch is an ancestor of verifyThe target is named, not implied, because the bound below is only checkable against an
explicit target. The object form is required for repair; the other failure policies stay
bare strings.
repair_budget is a global bound on repair rounds for the whole run — never per node.
That is deliberate: bound repairs per node instead and two nodes can repair each other
forever, each seeing its own counter as unspent. One global counter is what makes the run
provably terminate.
Total node executions are bounded by (1 + repair_budget) × Σᵥ max_attemptsᵥ.
(max_attempts is the total attempts a node may make, so a node with max_attempts: 1
contributes 1. In the retry-budget notation used by the scheduling literature, where bᵥ is
the number of retries, that is (1 + R) × Σᵥ(bᵥ + 1) — the same quantity.)
Per-node retry budgets alone do not bound a graph with repair — that is the whole point.
Repair is a bounded outer loop, not a cycle. Nothing is revived: a round boundary is
recorded as run.repair.round, each reset node gets a node.repaired receipt naming the
terminal state it left, and every receipt in a round carries that round. Within a round the
state machine is unchanged, so the audit trail stays verifiable — a replay refuses a boundary
whose trigger did not fail, whose failure was not one a run may continue past (a broken gate or a
denied policy cannot be repaired), whose target the trigger did not declare, that is numbered out
of sequence, or that exceeds the budget.
Refused at validation: a target that is not a strict ancestor (including the node itself), a
target that does not exist, repair with a repair_budget of 0, and repair under
fail_mode: fail_closed — where the run stops at the first failure, so a repair could never
begin. on_failure: continue and await_human remain declared-but-unimplemented and are
still refused.
Fail-closed preflight (checked before any node runs):
approvalnodes are not refused at preflight — they are skipped during preflight and the run pauses (exit code 3) when execution reaches them. Usebl graph approveorbl graph consoleto record a decision and resume (see items 12 and 13).- Any node whose binding is neither
local_clinorhttps(see item 8) is refused with an explicit message naming which phase will handle it — e.g. sandboxed tool execution. - An unknown
provider_id(not in the five profiles above) fails the node closed. - A missing prompt for a node fails the node closed.
- A CLI binary not found in PATH fails the node closed.
Every run writes a controller-events.jsonl event log tied to a GraphRunIdentity
(organization, project, run, graph digest, plan digest, policy digest). The event log
is append-only; each entry is chained to the previous by digest.
Verdict and artifact digest binding: The gate verdict and the node's artifact
digest are co-recorded in the same hash-chained event. The binding is structural:
both fields appear in the same immutable event record covered by the chain; neither
field cross-references the other outside of that shared record. Specifically, the
default StructuralAcceptanceGate does not populate GateVerdict.evidence_digest —
the evidence_digest field is optional and is only populated when a gate is explicitly
configured to do so.
Resume verification scope: On every resume call, the complete event log hash
chain is re-verified (event_log.replay() re-hashes every prior event), and artifact
bytes are re-verified on every open() call against the stored digest. SUCCEEDED
nodes are not re-driven through the gate on resume — the resume trusts recorded gate
verdicts so long as the hash chain covering those events is intact. Incomplete nodes
(those that did not reach SUCCEEDED in the prior run) are re-driven normally.
Every file bl graph status, arena, resume and approve reconstruct a run from:
| File | Written by | Contents |
|---|---|---|
plan.json |
execute_graph_run |
canonical execution plan bytes |
manifest.yaml |
execute_graph_run |
the original authoring manifest |
connections.json |
execute_graph_run |
admitted connection records |
run-meta.json |
execute_graph_run |
plan_id, org, project, run_id, policy_digest, fail_mode, compiler_version |
controller-events.jsonl |
the controller | hash-chained event log |
approvals.json |
bl graph approve |
durable human decisions, with the repair round each was made in |
artifacts/ |
the workers | per-node content-addressed artifact store |
published-effects.json |
a publish node | the effect burn ledger |
Two addressing modes: a hosted root (runs_root/org/project/run_id, every segment
containment-checked) and a flat single directory (--out <dir>, which bl graph run --execute
writes and bl graph approve opens). bl graph status and bl graph arena reconstruct the full
plan from these files and verify that the reconstructed plan_id matches the stored one before
reading any events. A tampered manifest or connections file produces a mismatch and the command
refuses to continue.
bl graph arena --run ./my-run [--out arena.html]Generates a self-contained, static HTML page from the persisted run directory. The Arena is read-only — it renders node states, artifact digests, routes, and the event log. It does not connect to any server; all data is embedded in the HTML.
bl graph status --run ./my-run [--json]Prints the same projection to stdout. Output includes a LOCAL/UNVERIFIED notice:
the projection is derived from the local event log, not verified against a hosted
server.
The render_state_markdown function produces a Markdown projection of any
ArenaProjection — the same content the Arena renders, as Markdown. STATE.md is a
read-only UX projection. It is never authority: the event log and receipts are the
source of truth. A run directory rendered to STATE.md is a convenience snapshot, not
a substitutable record.
The graph runtime provides two distinct memory ports:
Exact KV (GraphMemoryStorePort): tenant-scoped, namespaced working memory.
Put, get, search by namespace, delete. Values must be JSON-round-trippable and under
256 KB. One store per (organization, project) — no cross-tenant API. The reference
implementation is InMemoryGraphMemoryStore; a durable SLM-backed adapter satisfies
the same port and is a deployment binding.
Semantic recall (SemanticMemoryStorePort): recall by meaning, not by exact key.
Returns ranked results for a natural-language query within a namespace. Backed by SLM
(remember / recall). Callers must treat results as hints, not facts of record —
the event log is authority.
Both ports are UX / working state per ADR-12 D4. A memory value can never substitute for a receipt.
The EgressBroker authorizes one outbound request behind a single-use, time-bound,
effect-bound credential lease. It never hands a credential value to a node. Properties:
- Single-use: a
lease_idauthorizes exactly one request atomically; a denied request does not burn the lease. - Time-bound: an expired lease is refused.
- Effect-bound: the request's effect must be one the lease grants.
- SSRF/DNS-rebind protection: the destination host is resolved once; every resolved address must be a globally-routable public unicast address. Private, loopback, link-local (including 169.254.169.254), CGNAT, multicast, and reserved ranges are denied. The broker returns pinned addresses; the forwarder connects only to those.
The ConnectorForwardPort is a Protocol; the engine bundles HttpConnectorForwarder
as its concrete implementation, paired with EnvCredentialResolver (resolves the
credential from an environment variable named in the AdmittedConnectionRecord — never
the credential value itself). A deployment may substitute its own forwarder/resolver
(for example, keychain- or KMS-backed) but does not have to. Either way, the forwarder
connects only to the broker's pinned addresses; no credential value and no
request/response bytes pass through the broker itself.
Shipping status: SHIPPED. The broker, its SSRF protection logic, and the BYOK/HTTP
connector as a run mode are implemented, tested, and wired end-to-end — bl graph run --execute manifest.yaml --admitted admitted.json ... routes https-transport nodes
through this stack via _ByokDispatchWorker (execute_graph.py:1-15,128,247).
Preflight fails closed if an https node has no matching AdmittedConnectionRecord
(execute_graph.py:267-277).
bounded-loops-mcp exposes the graph runtime over MCP. The MCP tools validate inputs,
enforce tenant scoping, and translate calls into the graph use cases behind an injected
GraphRuntimeFacade. The MCP shim is engine-agnostic: it imports no workers, isolation
adapters, or connectors. Read tools are side-effect-free. Mutating tools (resume,
approve) gate on a confirm flag — confirm=False returns a preview and changes
nothing; confirm=True applies.
The MCP server never wires real workers, isolation, or connectors. The deployment
provides the GraphRuntimeFacade. Subject identity comes from the MCP session, not
from LLM arguments — a caller cannot spoof the authenticated subject.
AuditPlanService: validates audit coverage and persists plans. An AuditPlan
assigns each artifact cell to at least one independent auditor — the producer is never
the sole auditor of its own output. Plans are content-addressed by digest.
reconcile_audit: collapses multiple independent audit results per cell into a
release decision. Rules:
- Preserves the highest severity seen per cell (never lowers a finding).
- Flags DISSENT when lanes disagree.
- A release passes only when every mandatory cell has independent coverage AND carries no unresolved S0 or S1 finding.
- Reports every blocking reason in one pass (not first-failure).
The ReleaseDecision is advice for a human release owner per LLD 06. The event log
and receipts remain authority.
bl graph init [--posture {open,allowlist,broker}] [--allowlist <host[:port]>] [--yes] [--config <path>]Interactive installer that writes ~/.bounded-loops/egress.json (or the path in
--config). Defaults: OPEN posture + local_cli connector (frictionless first run).
Atomic write discipline: unique temp file (O_EXCL + O_NOFOLLOW) → fchmod 0600 →
fsync → verify_round_trip() (round-reads the temp through the resolver) → os.replace.
Refuses symlinks at the config path in every mode.
--allowlist is repeatable and comma-ok: --allowlist api.anthropic.com,api.openai.com.
--connector is informational only — not written to disk (the connector binding lives in
connections.json, not the egress config).
Config precedence (highest wins): explicit arg to decide_egress_posture() → env var
BOUNDED_LOOPS_EGRESS_POSTURE → config file ~/.bounded-loops/egress.json → default OPEN.
bl graph approve --run <dir> --node <id> --decision {approved,rejected} [--inputs <json>] [--json]Records a durable human decision for an approval node and resumes the run.
LocalGraphRuntimeFacade.for_run_dir(run_dir) addresses runs by flat path — no
runs_root + org/project hierarchy needed.
Durable machinery: build_durable_approval_resolver (shared between execute_graph_run
and the facade) persists to approvals.json under flock + atomic os.replace.
A bare resume after a crash never re-pauses a gate a human already decided.
Exit codes: 0 = SUCCEEDED, 2 = FAILED, 3 = AWAITING_APPROVAL (more approval nodes remain).
Local posture caveats: the approve command validates authority via the bundled
SameTenantApprovalAuthorizer (same-tenant check, no role verification) and
SameTenantApprovalSignatureVerifier (accepts any non-empty signature — the local run
directory is the auth boundary). A hosted, multi-tenant deployment must supply a
role-checking ApprovalAuthorizationPort and a crypto ApprovalSignatureVerifierPort.
The reject path is not signature-gated locally — reject is accepted on filesystem
writability alone for local posture.
A decision may be recorded before the node asks for it. approve does not require the
target node to be sitting at AWAITING_APPROVAL yet: a decision recorded ahead of time is
persisted and honoured when the run reaches that gate. This is deliberate — it is what lets
a scripted or unattended run carry its approvals with it, and it is how the durable resolver
is exercised end to end. It does mean the approver may not have seen the node's evidence at
the moment they decided, so treat it as a convenience of the local, single-operator posture
rather than a human-in-the-loop guarantee. A hosted, multi-tenant deployment that needs
"the human saw this exact evidence before deciding" must bind each decision to the node's
current hold evidence and reject a decision that arrives early.
bl graph console --run <dir> [--port <n>]Starts a loopback-only HTTP server (binds 127.0.0.1, never 0.0.0.0) that serves a
single-page approval UI. The port defaults to 0 (OS-assigned ephemeral port).
Security properties: per-invocation secrets.token_urlsafe(32) token embedded in the
URL; hmac.compare_digest (constant-time comparison); CSRF defense via Origin header
check (falls back to Referer); 8 KB body cap; 30-second handler timeout; HTTP/1.0
(no persistent connections); hardening headers (Referrer-Policy, X-Content-Type-Options,
Cache-Control: no-store). Auto-stops after the page is served (triggered from the GET
handler, never from a POST).
LOCAL posture caveat: the per-invocation token gates other local processes on the same host — it does not authenticate across a network. A hosted deployment must add TLS, real authentication, and a role-checking authorizer before exposing the console to external traffic.
A publish node is the one place a graph is allowed to do something it cannot undo. It
declares a publication_policy (a named string the deployment resolves; the node fails
closed without one) and its effect is recorded in published-effects.json by
LocalPublicationLedger.check_and_record, keyed on run_id / plan_id / node_id and
carrying a payload digest computed over the artifacts of every upstream node.
attempt and repair_round are deliberately NOT in that key, and the omission is the
guarantee rather than an oversight. Include attempt and attempt 2 fires the effect again after
attempt 1 already ran — with an event log that looks perfectly honest and a bank debited twice.
Include repair_round and a repair produces 1 + repair_budget publications, each with its own
tidy trace. The triple above is the only key that burns once per run, plan and node, which is
also why a repair round re-running a publish node is a no-op rather than a second effect.
The repeat behaviour is three-valued: an unseen key fires; the same key with the same payload
digest returns already_published; the same key with a different payload digest raises and
HALTs the run, because the effect was already burned with different content.
The ordering, stated exactly, because a diagram is easy to draw wrong. The ledger write is the effect in this local sink — it is not a write-ahead record of an effect that happens afterwards. The hash-chained event log is the WAL for run state, not a pre-effect entry for the publication. So:
- There is no
ledger → external call → ledgersequence to point at. Do not draw one. _load()then_save()is a read-modify-write with no lock. Two concurrent publish attempts on the same key can both observe it absent and both fire. An atomic compare-and-swap (fcntl.flock, or an atomic rename) is what the name "exactly-once" would require, and it is not there yet.- A production sink that calls a real external service needs the WAL entry written before
the outbound call, and a reconciliation pass for the crash-in-between case. That is a
deployment-provided
PublicationLedgerPort, not something this local implementation gives you. - A corrupt or unreadable ledger is refused, not treated as empty — an unparseable file HALTs rather than re-firing every effect it used to contain.
bl graph approve gating a publish node is what makes the ordering human-checkable: the
approval's authorized effect set is derived from the nodes reachable from it, so an approval
that gates nothing effect-bearing cannot silently authorize a publication.
Each of these has a real, tested mechanism in the codebase already. What is listed here is the specific gap between that mechanism and full production wiring — not an absence of the mechanism itself.
mcp_graph.py defines the GraphRuntimeFacade Protocol and the full MCP tool
surface. LocalGraphRuntimeFacade is bundled as the reference concrete implementation
(graph_runtime_facade.py): it wires real arena reads, real connector workers
(build_execution_controller), and the real approvals.approve use case for persisted
local run directories. Its approval authorizer and signature verifier default to
same-tenant-subject and non-empty-signature checks — a hosted, multi-tenant deployment
still must supply a role-checking ApprovalAuthorizationPort and a crypto
ApprovalSignatureVerifierPort for production-grade authority.
The audit plan service and reconciliation logic are implemented, and the read side is
wired as a runnable mode (C-079): bl graph run --execute --audit-plan <json>
persists the plan alongside the run, and bl graph arena computes the coverage table
and release verdict from it, failing closed if the projection cannot be computed
(cli_graph.py:858, execute_graph.py:596-617, cli_arena.py:70-90). Independence is
receipt-asserted — the assessor is never the producer. What remains deferred is the
write side: structurally binding a coverage cell to the AuditAssignment.model_id
and its succeeded-receipt route, rather than asserting independence from the plan alone.
The mechanism — NetworkMode.ALLOWLIST, a loopback-only OS cage plus a destination-
allowlisted CONNECT proxy (enforcer.py:25, providers/native.py, sandbox.py,
egress_proxy.py) — is shipped and proven live on macOS Seatbelt: a caged process can
reach only the proxy; everything else comes back denied_by_sandbox. Linux/docker/bwrap
fail closed (authorized egress is refused, never faked) rather than caging.
local_cli nodes are now wired to the configurable egress posture (Slice 2, C-081).
OPEN is the default (subscription CLI unchanged). ALLOWLIST is opt-in via bl graph init,
the BOUNDED_LOOPS_EGRESS_POSTURE env var, or ~/.bounded-loops/egress.json.
Fail-closed rule: ALLOWLIST without the Seatbelt cage present raises GraphValidationError
at preflight — it never silently falls back to OPEN.
Caveat: ALLOWLIST is a network-only cage. A compromised subprocess retains full read/write access to the filesystem (HOME, TMPDIR, workdir). It cannot make outbound TCP connections except via the loopback proxy, but it can still read or write local files.
What remains deferred is making ALLOWLIST the default tier for connector nodes —
today open remains the default. The cage and opt-in wiring are shipped.
bl graph status and bl graph arena use a no-op receipt verifier for local runs.
A hosted ArenaReceiptVerifierPort that verifies the hash chain against a remote
server is a later phase. The LOCAL/UNVERIFIED notice in bl graph status output
is the honest statement of this posture.
| Binding | What to provide |
|---|---|
| Agent CLI binaries | Install and log in to claude, codex, grok, muse, and/or agy on the host |
| BYOK admitted-connection records | A JSON file mapping connection_id to endpoint + credential ENV-VAR name (no secrets), passed via --admitted |
ConnectorForwardPort (optional override) |
The engine bundles HttpConnectorForwarder + EnvCredentialResolver by default; supply your own only for keychain/KMS-backed credential resolution instead of an env var |
GraphRuntimeFacade (hosted-grade override) |
LocalGraphRuntimeFacade ships as the reference concrete facade; a hosted deployment supplies a role-checking ApprovalAuthorizationPort and a crypto ApprovalSignatureVerifierPort in its place |
| Durable memory adapters | An SLM-backed GraphMemoryStorePort and SemanticMemoryStorePort for cross-run memory persistence |
AuditStorePort |
A concrete audit store (e.g., LocalAuditStore) for persisting audit plans |
| Hosted receipt verifier | A ArenaReceiptVerifierPort implementation if you want the Arena to verify hash chains against a remote server |
pip install bounded-loops installs pytest>=8.0 as a core runtime dependency.
This is intentional, not a packaging error.
The engine ships a built-in pytest gate kind. When a bounded loop declares
gate.kind: pytest (in loop.yaml), the engine invokes python -m pytest as a
subprocess at run time — not as a test framework for this project's own test suite,
but as the independent gate that checks the workspace. Because that subprocess call
is part of the engine's runtime path (not just a development or CI tool), pytest
must be present in the same environment as the engine itself.
Note: kind: loop as a graph node kind is distinct from a standalone loop using
a pytest gate. kind: loop graph nodes ARE executable via bl graph run --execute as of 0.5.0
in this release — preflight refuses them. See the capability matrix above.
A test (test_default_install_includes_pytest_for_shipped_pytest_gates) asserts this
dependency is present and reachable, specifically to prevent well-meaning packaging
cleanup from moving pytest to a dev-only optional group and silently breaking the
pytest gate for users who install only bounded-loops.
If you install bounded-loops and never use the pytest gate, pytest is an unused
runtime dependency in your environment. That is the accepted tradeoff.