ActiveGraph 1.2.0 to 1.7.1: hardening the reference packs pulled the runtime forward
A coordinated push across two repos: the reference packs got hardened for autonomous-agent use, and the activegraph runtime shipped seven releases in a day to support them. The through-line is one loop — fork, trial in a child process, owner approval against pinned bytes, quiescent promote, watch window — recorded end-to-end in the event log.
- release
- activegraph
- packs
- self-modification
- runtime
- promote
As we prep to build a full autonomous agent on top of ActiveGraph, we wanted to harden the reference packs first. The packs are the capability layer: memory, tool gateways, identity, channels, and the self-modification loop that lets an agent author and adopt its own packs. Hardening that layer meant stress-testing it against the failure modes an autonomous agent would actually hit, and that stress-testing kept surfacing gaps in the runtime underneath. So this turned into a coordinated push across two repos: the packs got hardened, and the runtime (activegraph, on PyPI) shipped seven releases in a day to support them, 1.2.0 through 1.7.1.
The through-line is one loop. The runtime already had fork(at_event=...), which branches a run into an isolated child carrying the parent's history, and rt.diff(fork), which compares the two structurally. What it did not have was a way to adopt a fork's work back into the parent, so fork-test-promote was fork-test-and-then-redo-by-hand. promote (1.3.0) closed that. Everything after it, across seven releases, was the safety, portability, and provenance work needed to make that one loop trustworthy when an autonomous agent drives it: a manifest and a bundle hash so an approval pins exact bytes (1.4.0), subprocess isolation so a candidate's first execution happens outside the parent process (1.5.0), disable and retention so adoptions can be rolled back and their provenance cannot be garbage-collected (1.4.0 through 1.6.0), and the sandbox portability fixes that let the trial child run anywhere the parent can (1.7.0, 1.7.1).
promote itself is the design decision that set the tone. It applies a fork's net state delta to the parent as ordinary new events, so the parent's log records what actually happened to it ("at this point I adopted this delta from that fork") instead of copying the fork's never-executed llm.requested and tool.requested pairs into the parent's history. Copying them would fabricate history the parent never lived, and every replay and audit primitive trusts the log to record what actually happened to that run. Conflicts are entity-level and fail-closed. Apply is quiescent: the delta events do not re-fire behaviors, because the fork already ran every cascade the delta implies, so the parent reacts exactly once to a single promote.applied marker.
This post is what changed in detail, then what it enables in plain terms, then how we built it and what we learned.
What this looks like from the top
The packs gained a governed loop where an agent can draft a new capability, trial it in isolation against its own history, and adopt it only after an owner approves the exact reviewed bytes, with every step recorded as an event you can replay. A capability gap becomes a proposal, which passes static gates, then a fork trial in a child process, then a mandatory identity-verified owner approval, then a quiescent promote, then a watch window. None of those stages requires trusting the model to be safe. Static gates decide whether a draft is well-formed. A child process that cannot reach the parent decides whether it runs cleanly. A verified human decides whether it adopts, against bytes pinned by a hash. The safety is in the structure, which is what makes it hold when the model is wrong or the input is hostile.
What changed, in detail
The runtime
Promote's referential integrity. Beyond the state-application and quiescent-apply decisions above, the conflict check runs in both directions. A promoted relation can never point at an object that will not exist in the parent after the promote, and a promoted removal can never silently delete the parent's own work through a cascade. The whole plan is computed before any mutation, so one conflict fails the entire promote and applies nothing. There is no partial promote and no force flag: the escape hatch for a conflicted adoption is to re-fork from the parent's current tip and re-trial, which replays the change against the parent's actual present instead of pretending a merge is safe.
The manifest and the bundle hash. Packs got a machine-readable manifest: identity, dependencies, the declared capability surface, and a content hash that pins the bytes. The runtime owns the validator, so the loader, CI, and the self-modification gates all check against one implementation. A subtle hole showed up during review: the content hash excludes manifest.toml (a hash cannot cover itself), which means an attacker could swap only the manifest (relabel risk classes, empty the outbound-reach list, flip the author flag) and the content hash would still match. The fix is a bundle hash that covers the manifest too. External pins use the bundle hash; the manifest's internal hash keeps the self-exclusion. The document an owner reviews can no longer be swapped after approval.
Disable and retention. disable_pack removes a pack's behaviors, tools, and validators from the live registries so nothing fires afterward, emits a pack.disabled event, leaves state untouched, and is idempotent. True unload of imported Python is not honestly achievable, so we say so: disable stops firing immediately, and a restart evicts the code from memory. Retention adds snapshot and archive tiers that never delete, with a pin set that dominates retention policy: a fork referenced by any live promote.applied marker is pinned whole, so the provenance behind an adoption cannot be garbage-collected out from under it.
Subprocess trial isolation. Trials of candidate code now run in a fresh child interpreter, materialized from artifacts pinned by the bundle hash, under three independent nets (address-space limit, wall-clock kill, event budget). The child is the first end-to-end consumer of the full manifest chain: bundle-hash verify, then manifest load, then a two-way surface check, all before any import. We are honest about the boundary in the docs: this is crash and state isolation, not a security sandbox. Syscall and network confinement stay host territory.
Provider, DX, and diagnosability. Tool-name sanitization at the provider wire boundary (dotted pack-scoped names were breaking function calling on both providers), an error taxonomy split so a revoked key is terminal instead of retried as a network flake, per-family model-parameter handling, registration-time signature validation across all decorators, and event and failure accessors on the trace surface so pack development is not archaeology. Late in the day the sandbox stopped discarding the trial child's stderr, so a child that crashes before reporting now carries its real error instead of an opaque "crashed."
The packs
The self-modification loop. A capability gap becomes a proposal, which passes static gates (manifest validity, hash integrity, declared-versus-actual surface, an import allow-list, banned constructs, reserved namespaces, size caps, and an injection scan), then a fork trial with in-sample and held-out discipline borrowed from our eval work, then an owner approval that is mandatory and identity-verified (adoption refuses to even register in an unverified-identity deployment), then a quiescent promote, then a watch window. Every stage is graph state. The differentiating claim the loop had to earn is self-modification with provenance: the trial is a fork you can diff, the adoption is a marker you can trace, and the rollback is a state you can reconstruct.
Origin-based context isolation for the author. The riskiest component is the part that reads a gap and drafts code with a model, because that is where prompt injection meets code generation. The design closes it by classifying admission to the author's context by origin, never by content. The author frame is assembled by pack code, four fixed sections (a hash-pinned charter, structured gap fields only, the target surface from source of truth, and verified-owner text), and nothing else. Memory, profile goals, tool output, web text, unverified-sender messages, and prior proposals' rationales are permanently excluded, because exclusion needs no correctness proof while neutralization does. The author holds zero tools during drafting, since every tool call is a channel for exactly the text the frame excluded. A drafting record is sealed before the model runs, and taint is recomputed from the admitted ids at submission, so no model output can launder a flag away. A fixture plants poisoned memory, profile, tool output, and a prior rationale in the graph and proves none of them reach the frame.
Everything else the reference layer needs. Bidirectional MCP so the agent can consume external tools under the gateway and be consumed as a server under the same trust layer, hybrid memory retrieval with pluggable backends, managed auth behind a credential resolver, a capability catalog so discovery replaces a hardcoded allow-list, and a decision surface that renders a proposal (what the author read beside what it wrote, with the full diff, the gate verdicts, the trial evidence, and any injection flags) so approving code means reading it.
What this enables
The point of all of this is a foundation where an autonomous agent can extend its own capabilities without any step requiring trust in the model. It can propose a change, but only static gates decide whether the change is well-formed. It can run the change, but only in a child process that cannot reach the parent. It can ask for adoption, but only a verified human resolves the hold, and only against the exact bytes that human reviewed. And because the runtime is event-sourced, the entire history (the gap, the draft, the gate verdicts, the trial, the approval, the adoption) is a log you can replay and audit after the fact. The safety is in the structure, which is what makes it hold when the model is wrong or the input is hostile.
How we built it, and what we learned
We ran two build sessions in parallel, one per repo, passing courier messages between them, with all seven runtime releases tagged and published by hand. A few things made it work, and a few things it taught us.
Design before code, review before build. The two hardest artifacts, the promote semantics and the LLM author, each got a written design and an independent review before a line of implementation. Promote's design was amended three times before it was built (quiescent apply, referential-integrity conflicts, plan-versus-apply recompute), and every amendment was cheap on paper and would have been expensive in code. The author design's review produced four required changes, each one turning an asserted trust boundary into an enforced one with a fixture. The stated goal of both designs was to make the build boring. That is a good goal. Boring builds are the ones that ship correct.
One implementation, many consumers. The content and bundle hashes get recomputed by the loader, by CI, and by the self-modification gates. Rather than three implementations drifting apart, the runtime owns one and the others import it. The moment you have the same rule enforced in three places, put it in one place.
Honest boundaries beat convenient ones. The sandbox is crash isolation, not a security sandbox, and the docs say so. On macOS the trial's memory net cannot be set, so it announces that it is off rather than faking a cap, because a memory net that quietly does nothing is worse than one that says it is off. Every time we were tempted to paper over a limit, saying it plainly was the better call, because a consumer who knows a net is off can plan around it, and a consumer who thinks a net is on when it is off cannot.
The soak earned its keep by failing. We built an endurance harness that rehearses the entire self-modification loop every few minutes and checks that every part behaves: adoption, conflict-parking, disable-survives-restart, all three budget nets, injection refusal. It went red three separate times, and every red was a real issue that would have hit the agent on the developer's own machine, caught in minutes on the first rotation instead of in production. The trial child could not import the runtime under the sandbox's minimal environment, on every non-CI box, not one platform. Then the address-space limit crashed the child on macOS. Then a budget scenario was asserting a Linux-only outcome after the memory net became honestly platform-conditional. None of these were regressions. Each was the next real thing surfacing once the previous one cleared, which is exactly what an endurance test is for. The lesson we keep relearning: the failures a harness finds before you trust the system are the cheapest failures you will ever get.
Fix the thing the failure points at, not the symptom. When a budget scenario broke on macOS, the cheap fix was to skip it there. That would have stopped testing runaway-memory containment on the exact platform the agent runs on. The honest fix made the scenario test the invariant that actually matters (a runaway is contained) via whichever net protects the platform (the address-space limit on Linux, the wall-clock kill on macOS). A red flag is a pointer to a real question. Answer the question.
The runtime stayed domain-neutral through all of it. No assistant opinions, no product-specific logic. It is physics: event log, graph projection, behaviors, fork, diff, promote, and now the sandbox and retention to make self-modification safe. The opinions live in the packs, and the product lives on top of the packs. Keeping those layers clean is what let two repos move this fast without stepping on each other.
← back to blog