Skip to content

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

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.

Freeze now, sign later (with a specific trigger).

  1. The context assembler in @agent-platform/runtime defensively clones its inputs via structuredClone, then recursively Object.freezes the resulting bundle before returning it. Any mutation attempt on the returned bundle throws TypeError under module strict mode.
  2. 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.
  • 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 any or calls in from plain JS hits a TypeError instead 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.
  • structuredClone pins 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 introduces Map, Date, typed arrays, or Error values, structuredClone still 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-patch structuredClone, 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.freeze solves 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 outside git — hence the trigger condition above.
  • @agent-platform/runtime ships today with assembleContext that validates, clones, and freezes. Tests cover each behavior individually (context-assembler.test.ts).
  • The architecture.md threat 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.
  • Object.freeze on 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 Proxy wrapper instead of Object.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 with structuredClone or JSON serialization. Object.freeze is 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. readonly is 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 ContextBundle is 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.