ADR-0012: Runtime immutability enforcement for assembled context bundles
ADR-0012: Runtime immutability enforcement for assembled context bundles
Section titled “ADR-0012: Runtime immutability enforcement for assembled context bundles”Status: Accepted Date: 2026-04-21
Context
Section titled “Context”ADR-0006 commits the platform to a six-layer context system in which layers 1–2 (Core Context, Characteristics) are immutable — an attacker who controls only layers 3–6 (delegated tasks, shared context, memory) must not be able to override an agent’s identity, system prompt, or hard constraints. ADR-0006 deferred the question of how that immutability gets enforced at runtime. The core types mark layers 1–2 readonly, but readonly in TypeScript is a compile-time decoration erased at build time; it is defeated by a single as any, a plain-JS caller, or a well-placed Object.assign.
ADR-0011 ships DelegatedContextSchema.strict(), which handles one half of the problem — the injection vector. A caller that tries to smuggle core_context inside a delegated task has the payload rejected at the validator boundary. That closes the wire-protocol attack surface.
The remaining attack surface is in-memory, after assembly: once a ContextBundle has been built and is travelling through the runtime toward the LLM call site, any code holding a reference to it can still mutate layers 1–2 in place. This ADR decides how we prevent that.
Two candidates were considered in open-questions.md#runtime-enforcement-of-immutable-context-layers (now resolved by this ADR): Object.freeze and cryptographic signing of agent definitions. They are not mutually exclusive.
Decision
Section titled “Decision”Freeze now, sign later (with a specific trigger).
- The context assembler in
@agent-platform/runtimedefensively clones its inputs viastructuredClone, then recursivelyObject.freezes the resulting bundle before returning it. Any mutation attempt on the returned bundle throwsTypeErrorunder module strict mode. - Cryptographic signing of agent definitions is explicitly deferred, with the trigger to revisit stated plainly: the first time an agent definition is loaded into the runtime from any surface outside the signed repository — a remote registry, a user upload, a database row written by a management UI, a hot-reload system that reads from disk at runtime — ADR-0012 must be revisited before that surface is shipped.
Consequences
Section titled “Consequences”- ADR-0006 is now runtime-enforced end-to-end. Injection is stopped at the schema boundary; post-assembly mutation is stopped by the freeze. A downstream consumer that reaches for
as anyor calls in from plain JS hits aTypeErrorinstead of silently corrupting Layer 1. - Defensive clone matters as much as the freeze. If we froze inputs in place, we would mutate the caller’s own references — every piece of upstream code holding those references would start throwing on write. Cloning detaches the bundle from the caller’s object graph so the freeze is local to the bundle.
structuredClonepins us to data that is structured-cloneable. All current context types are plain JSON-shaped (strings, numbers, booleans, arrays, plain objects, branded strings). If a future layer introducesMap,Date, typed arrays, orErrorvalues,structuredClonestill handles those. If a future layer introduces functions, class instances, or cyclic references, the clone throws and we’d need to reconsider. Unlikely but worth recording.- Memory and CPU cost is real but bounded. Deep-freezing a typical bundle touches perhaps a few dozen objects — negligible per turn. The clone is O(n) in the graph size and allocates a second copy; acceptable for the LLM-per-turn budget. We are not freezing on a hot inner loop.
- We are not protecting against a compromised runtime. If attacker code is running inside our process with the ability to replace
Object.freeze, monkey-patchstructuredClone, or intercept the assembler module, this control does nothing. That is a different threat (supply-chain / sandbox-escape) handled by other controls (lockfile discipline, code review, future CI signing). - Signing deferral is principled, not lazy. Today, agent definitions live only in the signed repository. The threat model is “bugs in our own code mutate the bundle” — a static analysis problem that
Object.freezesolves at negligible cost. Signing adds key management, key rotation, build-time signing infrastructure, and a new class of deployment failures (unsigned but valid definitions in a staging environment) in exchange for defending a threat that does not yet exist. The cost/benefit flips the moment any agent definition is sourced from outsidegit— hence the trigger condition above.
Consequences for the repo
Section titled “Consequences for the repo”@agent-platform/runtimeships today withassembleContextthat validates, clones, and freezes. Tests cover each behavior individually (context-assembler.test.ts).- The
architecture.mdthreat model now lists “post-assembly in-memory mutation” as controlled, and “unsigned agent definition” as a known gap with a defined trigger. - When the trigger fires, this ADR should be superseded (not amended) with an ADR that describes the signing approach chosen, the verification point in the runtime, and the key-management story.
Alternatives considered
Section titled “Alternatives considered”Object.freezeon the callers’ input objects (no clone). One less allocation per turn, but mutates the caller’s graph as a side effect — specifically, it freezes objects the caller may still hold and expect to mutate. We’d be trading a correctness property (callers own their refs) for a micro-optimisation on a per-LLM-call code path. Not worth it.- A deep-readonly
Proxywrapper instead ofObject.freeze. Proxies can intercept writes and throw with richer error messages, but they add a lookup cost to every property access, complicate equality checks, and don’t interoperate cleanly withstructuredCloneor JSON serialization.Object.freezeis supported natively by every JS engine we target (V8 in Node 22 and Workers) and needs zero infrastructure. - Sign agent definitions now. The right answer if we already load definitions from outside the repo. We don’t. Doing this today would add signing infrastructure without a threat to defend against, and the signing code would have to be written, tested, and maintained for the interval between “we built it” and “we actually start loading external definitions.” Paying that cost up-front in exchange for defending a hypothetical attack is the thing the project-first principle warns against.
- Rely on
readonly+ code review. The option ADR-0006 implicitly rejected by flagging the open question.readonlyis a compile-time lint; any downstream contributor (or Business Pack author) who reaches for an escape hatch circumvents it silently. Code review catches that the first few times and stops catching it after the reviewer stops being paranoid. Freezing is a stable mechanical guarantee with a small, understandable implementation. - Freeze at every trust boundary (e.g. on every event dispatch, on every memory write) instead of only in the assembler. Plausible extension, but premature: the assembler is the first and currently only place a
ContextBundleis produced. When other producers appear (e.g. a snapshot/restore path), they will each need their own freeze, and the pattern can be extracted into a shared helper at that point. Doing it now would be generalising from a single call site.