« Phase 20 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes
Phase 20 — Deep Dive: Amazon Bedrock AgentCore
The load-bearing idea across all three labs is the same: isolation and authorization are enforced by structure, not by discipline. A session cannot leak because it holds no reference to another session's state. A forbidden tool call cannot fire because the target is never reached. A re-run consolidation cannot duplicate because the write path checks identity before it appends. Each of these is an invariant you can point at in the data structures, not a convention you hope everyone honors. This doc traces the mechanisms that make those invariants hold, and shows where the naive version breaks.
Runtime: the isolation invariant lives in a reference, not a check
The Runtime's state is sessions: dict[str, Session], where each Session owns a private store: dict. The entire security claim reduces to one line in _build_context: the RequestContext is constructed with store=session.store. The entrypoint receives a reference to this session's dict and no handle to self.sessions. There is no API — no method, no field — by which running entrypoint code can name another session's store. Isolation is therefore not something the invoke path checks; it is something the object graph makes unrepresentable.
Contrast the naive design that fails at exactly this level: a single shared store keyed by session_id, with the entrypoint handed the whole map "for convenience." Now isolation depends on every entrypoint always indexing with its own key and never iterating — a property no type enforces and one prompt-injected line of generated code violates. The lab's structure removes the capability; the real Runtime removes it harder, by putting each session in its own Firecracker microVM with its own kernel and address space.
Worked trace — invoke("session-A", {"prompt": "hi"}):
_build_contextfirst assertsapp.has_entrypoint; aBedrockAgentCoreAppwith no@app.entrypointraisesNoEntrypointError— the miniature ofagentcore launchrefusing to deploy an app with no registered handler._acquire("session-A")computescold = "session-A" not in self.sessions. On a first call,cold=True; it mints_next_tick(), creates theSessionwithcreated_tick=last_tick=tick, then calls_maybe_evict(protect="session-A").RequestContextis built with the isolatedstore, a monotonicinvocation_idfrom_next_invocation_id(), andcold_start=cold.- The entrypoint runs. If it returns a generator (a streaming agent),
inspect.isgeneratoris true andinvokematerializes it withlist(result);streaminsteadyield froms it chunk by chunk. - Only after the body completes does
session.invocations += 1run. This ordering is the definition ofis_warm: a session is warm iff it exists andinvocations > 0, so a session mid-first-invocation is not yet warm, and a crash before completion leaves it cold. That is deliberate: warmth means "has successfully served," not "has been touched."
The tick counter is the ordering primitive. Every acquire bumps last_tick, giving a total order over sessions by recency without a wall clock — which is what makes eviction deterministic and reproducible in tests.
The microVM pool: LRU eviction as a bounded map
max_sessions models the finite warm-pool the real Runtime maintains. _maybe_evict runs after each cold create: while len(sessions) > max_sessions, it selects victim = min(sessions, key=last_tick) excluding the just-created protect id, and deletes it. Evicting a session is sessions.pop(...); the next invoke for that id sees cold=True again and pays a fresh cold start. The invariant: the live pool never exceeds max_sessions, and the victim is always the least-recently-used session other than the one we are protecting. The protect argument matters — without it, a burst of new sessions on a pool of size 1 could evict the session you just created before it ever runs. Complexity is O(P) per eviction over the pool size P; fine because P is small and bounded by design.
Cedar Policy: a two-pass scan that makes forbid-overrides order-independent
PolicyEngine.evaluate is where a subtle correctness argument hides. Cedar's contract is two rules: default-deny (no matching permit means deny) and forbid-overrides (any matching forbid wins over every permit). The naive implementation — one loop, "first matching rule wins" — is wrong, because the decision then depends on the order rules were added. Put a broad permit before a narrow forbid and the forbid never fires.
The correct mechanism is two independent passes:
for r in rules: if r.effect == "forbid" and r.matches(req): return DENY
for r in rules: if r.effect == "permit" and r.matches(req): return ALLOW
return DENY # default-deny
Scanning all forbids before any permit is what makes forbid-overrides hold regardless of insertion order — the property is structural, not a function of how the policy set was authored. PolicyRule.matches ANDs four conditions: principal, action, resource each equal to the request field or the wildcard ANY, and when(context) — the injected Cedar condition predicate over {"arguments": ..., "principal": ...}. Complexity is O(R) over the rule count, two linear scans; no indexing, because policy sets are small and evaluated on every call.
The Gateway pipeline: order is the security property
Gateway.call_tool runs a fixed five-step pipeline, and the order is the whole point:
- Authenticate —
identity.verify(principal, credential); a missing or wrong credential raisesUNAUTHENTICATED(-32001) before anything else. - Resolve the tool by name (
METHOD_NOT_FOUNDif unknown). - Validate required args against
input_schema(INVALID_PARAMSif missing) — a pure check, no side effect. - Authorize — build the Cedar
Request(action="callTool", resource=name, context={...}),policy.evaluate, raiseFORBIDDEN(-32003) if denied. - Execute — only now
tool.invoke(arguments).
The invariant that the lab tests explicitly: a denied call has no side effect. Because authorize precedes execute, the transfer_handler's audit_log.append(...) never runs on a deny; the test snapshots the log, attempts three denied transfers (a reader with no permit, treasury over its <= 100 limit, anyone at >= 1000 hitting the forbid), and asserts the log is byte-for-byte unchanged. Move the policy check after tool.invoke and the money moves before the "no" lands — the classic check-after-effect bug. The step-4-before-step-5 ordering is not a style choice; it is the property.
Memory consolidation: idempotency by identity, ranking by stable sort
Short-term is short_term: dict[session_id, list[Event]] with a monotonic _event_seq; create_event is the only writer. Long-term is long_term: dict[namespace, list[MemoryRecord]]. consolidate(session_id) runs each Strategy over the session's events. If the namespace template contains {actor}, it partitions events by actor (sorted({e.actor for e in events})) so each user's records land in their own namespace like /facts/user-42; otherwise it formats with session_id (e.g. /summaries/s1).
The idempotency invariant lives in _add_record: it dedups by (namespace, content) — if any existing record in the bucket has equal content, it returns None and appends nothing. So re-running consolidate adds zero records; the lab asserts the second call returns an empty list. Why this matters at the mechanism level: extraction is meant to run repeatedly as a session grows, and a real strategy is a model that will re-emit the same fact. Without dedup, memory grows without bound and retrieval returns N copies of "user likes hiking." Idempotency makes consolidation safe to run on every turn.
Retrieval ranks with a single stable sort. For each record in the namespace, cosine(query, content) (bag-of-words: tokenize, term-frequency vectors, normalized dot product) is computed; zero-similarity records are dropped; the rest sort by key=(-score, seq). The -score sorts by relevance descending; the seq tie-break sorts equal-scoring records oldest-first, which makes the top-k deterministic under ties — the same query always returns the same ordering. That determinism is the entire reason the lab can assert exact retrieval results without a fuzzy comparison. Complexity is O(N log N) over the namespace's records; the real store swaps bag-of-words cosine for an ANN index over learned embeddings, but the ranking shape — normalized similarity, descending, stable — is identical.
The through-line
Three services, one discipline: put the guarantee in the structure. The isolated store reference, the two-pass forbid-first scan, the authorize-before-execute order, the dedup-on-content write, and the stable (-score, seq) sort are each an invariant a reader can verify by inspection. That is the difference between a system you hope is safe and one that is safe by construction — and it is exactly what the Principal Deep Dive scales up to a production platform.