« Phase 17 · Warmup · Lab 01
Deep Dive — The Composed Path, Step by Step
Table of Contents
- 1. What we are actually building
- 2. Step 1 — the channel, and building the principal once
- 3. Step 2 — admission, and the shape of a fail-static control plane
- 4. Step 3 — retrieval, and why the barrier goes first
- 5. Step 4 — the first guardrail pass, and where taint is born
- 6. Step 5 — routing, and the fallback that breaches residency
- 7. Step 6 — delegation, and the hop that loses the human
- 8. Step 7 — the second guardrail pass, and the taint rule
- 9. Step 8 — the gateway, and why its checks run after a block
- 10. Step 9 — four outcomes
- 11. Step 10 — the evidence check
- 12. Step 11 — telemetry and the hash chain
- 13. The defence-depth harness
- 14. The chaos suite
- 15. The degradation ladder, enforced
- 16. Determinism, and why it is a design requirement here
- 17. Failure modes, catalogued
- 18. References
1. What we are actually building
One function:
def handle(self, request: Request) -> RunResult
and two harnesses that attack it. The function is 200 lines; the difficulty is entirely in the ordering and in what each step is allowed to conclude.
The return type is worth reading before the body:
@dataclass(frozen=True)
class RunResult:
trace_id: str
outcome: Outcome # completed | degraded | escalated | denied
answer: str
denials: Tuple[Denial, ...] # every control that ACTED
artifacts: Tuple[Mapping[str, Any], ...] # the evidence pack
cost_micros: int
latency_ms: int
steps: int
degraded_rungs: Tuple[str, ...]
evidence_complete: bool
evidence_missing: Tuple[str, ...] # NAMED
answer is one field of eleven. That ratio is the design.
2. Step 1 — the channel, and building the principal once
emit("session", user=..., channel=..., tenant=..., chain=principal.describe())
Three decisions here, each of which is a bug elsewhere if you get it wrong.
The principal is built once. At the edge, from the human's token. Every later hop derives from
it via delegate_to. The alternative — each layer reconstructing identity from headers it was handed
— is how the human disappears, and it disappears silently because each reconstruction is locally
reasonable.
emit() is a closure over the trace id.
def emit(kind, **attrs):
artifacts.append({"kind": kind, "trace_id": request.trace_id,
"tick": self.now(), **attrs})
The join key is set in one place. Not because it is tidier, but because the realistic failure is that seven artifact types carry it and one does not — and the one that does not is discovered by an auditor, six months later, holding an approval record that cannot be linked to an action. Make it impossible to emit without the key and the entire class of bug is gone.
deny() is also a closure, and it takes blocking. See §9 and the Warmup's §6.
3. Step 2 — admission, and the shape of a fail-static control plane
if not self.control_plane_available:
self.alarms.append("control plane unreachable; serving the last known-good bundle")
if self.policy_stale_ticks >= 1800:
deny(Layer.CONTROL_PLANE, "hard-stop", f"policy bundle is {…} ticks old")
The three-part structure is the whole answer to "what happens when your control plane is down":
- Serve. The last known-good bundle — the last that validated, not the last received.
- Alarm. Unrouted, this is fail-open with extra steps.
- Hard-stop past an age. This is what stops fail-static degenerating into fail-open across a long outage, and the age is a policy parameter you should be able to defend.
Then admission itself:
def _admit(self, request):
if agent_id not in REGISTERED_AGENTS:
return False, [f"{agent_id} is not registered"] # default deny
if agent["state"] != "active": reasons.append(...)
if now() - agent["last_evaluated"] > agent["max_eval_age"]: reasons.append(...)
Unregistered returns immediately: there is nothing else to say about an agent with no record, and continuing would mean checking fields on a dict that does not exist. Absence of a record is not permission — this is Phase 09's default-deny at its interface.
And the emit is unconditional:
emit("policy_decision", effect="allow" if admitted else "deny", …)
A denial is a decision. A decision with no record is indistinguishable from a control that never ran, which is exactly the question an auditor asks: not "show me the denials" but "show me that the control was evaluated on every request."
4. Step 3 — retrieval, and why the barrier goes first
for document in self.corpus:
if document.barrier and document.barrier not in clearances(principal): # ①
removed.append(f"{document.doc_id} is behind {document.barrier}"); continue
if document.desk and document.desk != principal.desk and \
document.classification == "restricted": # ②
removed.append(...); continue
if RANK[document.classification] > RANK[principal.clearance]: # ③
removed.append(...); continue
kept.append(document)
The order ①②③ is load-bearing and the reason is subtle: an information barrier is not a clearance
level. A Project Falcon deal memo is classified confidential. A payments investigator holds
confidential clearance. Check ③ first and the memo passes — it is exactly at their level. The
barrier is an orthogonal dimension: a named deal that only a named list may see, regardless of
rank. Getting the order wrong here is an MNPI leak that every individual check would report as
working correctly.
Second: removals produce reasons, not silence.
"falcon-memo is behind deal:PROJECT-FALCON"
not "access denied". The reason goes into the evidence pack and into an operator's hands. "Access
denied" starts an investigation; the sentence above ends one.
Third: the artifact records versions and a snapshot:
emit("retrieval", doc_versions=("case-note-991@v3", "beneficiary-registry@2026-03-10"),
retrieval_snapshot="idx-2026-03-11T06:00Z", …)
The snapshot is Phase 15's forgotten pin. Pin the model, the prompt, the policy, the tools and the guardrails, and re-run six months later against a re-indexed corpus, and you get a different answer with five matching pins and no explanation.
5. Step 4 — the first guardrail pass, and where taint is born
for document in documents:
score = injection_score(document.text)
if score >= self.config.injection_block_threshold:
deny(Layer.GUARDRAILS, "injection-scan", f"{doc_id} scored {score:.2f}; dropped",
blocking=False) # ← acting, not halting
else:
tainted_sources.add(document.doc_id) # ← survivors are TAINTED
Read the else branch twice. The documents that passed the scan are the ones marked tainted.
That is not a mistake and it is the single most important line in the file. A document that fails the scan is gone — it cannot influence anything. A document that passes is in the prompt, and passing a scanner is not evidence of benignity; it is evidence that the scanner did not match. Everything that came from retrieval is attacker-influenceable, so everything that came from retrieval carries taint.
The scanner itself is a noisy-OR over string markers:
1 - Π(1 - weight_of_each_matching_marker)
Deliberately weak. Summing weights exceeds 1.0; taking the max throws away corroboration; noisy-OR does neither and treats each signal as independent evidence. But the design does not depend on the scanner being good — §8 is why.
6. Step 5 — routing, and the fallback that breaches residency
ROUTES = (
Route("gpt-frontier-uaenorth", "uaenorth", 3_000, 12_000, "restricted", 0.94),
Route("gpt-small-uaenorth", "uaenorth", 300, 900, "restricted", 0.81),
Route("gpt-frontier-westeurope","westeurope", 2_000, 8_000, "internal", 0.94),
)
The third route is the trap, and it is a realistic one: cheaper than the primary, identical quality,
and in the wrong country. Every gate lives inside _route:
for route in ROUTES:
if route.model in exclude: continue
if RANK[classification] > RANK[route.max_classification]: reasons.append(…); continue
if route.region not in self.config.residency_regions: reasons.append(…); continue
if projected_cost > budget: reasons.append(…); continue
if self.degradation.is_shed("smaller-model") and quality>0.85: reasons.append(…); continue
return route, reasons
return None, reasons
Inside, not beside. If residency were checked at the call site for the primary and the fallback
were selected by a separate pick_fallback(), both functions would be correct and the composition
would not be. Every route — primary, fallback, fallback's fallback — goes through the same gates
because there is only one place where routes are chosen.
Then the caller, and the bug that took three rounds to see:
route, reasons = self._route(classification, cost)
if route is None:
for reason in reasons:
deny(Layer.MODEL, "routing", reason)
Only an exhausted route list denies. reasons accumulates while walking the list — including
reasons for routes that were skipped before an eligible one was found. Logging those as denials
inflates defence depth and reports a successful fallback as a failure. A reason recorded on the way
to a success is diagnostics; only the empty-handed return is a refusal.
The fallback loop:
while attempts <= self.provider_failures:
if attempts <= self.provider_failures:
fallback, fallback_reasons = self._route(classification, cost,
exclude={route.model})
if fallback is None:
for reason in fallback_reasons: deny(Layer.MODEL, "fallback", reason)
break
self.alarms.append(f"{route.model} unavailable; falling over to {fallback.model}")
route = fallback; continue
response = self.model(...)
Two properties: the fallback fires only if it fits the budget (a fallback that blows the budget is a plan that works only when it is not needed), and it alarms — a silent fallback is a quality regression nobody can attribute later.
The inference artifact carries all six pins:
emit("inference", base_model_version=…, prompt_version="pi-v7", policy_version=…,
tool_set_version="ts-v3", guardrail_version="gr-2026-02", temperature=0.0, …)
7. Step 6 — delegation, and the hop that loses the human
def delegate_to(self, agent: str) -> "Principal":
if agent in self.chain or agent == self.user_id:
raise ValueError(f"{agent} is already in the chain {list(self.chain)}")
return replace(self, agent_id=agent, chain=self.chain + (agent,))
Two lines and both are the point.
The chain appends. chain + (agent,), never chain = (agent,). The human sits at the head and
stays there. Every audit record downstream can name an accountable person because the person is
still in the structure.
A cycle raises. A → B → A is an unbounded delegation loop, and it is not hypothetical: two agents that each consider the other authoritative for a sub-question will do this on the first ambiguous input. The refusal names the chain, so the operator sees the loop rather than a recursion limit.
The artifact carries the whole chain:
emit("delegation", to="group-compliance-agent",
chain="layla.almansouri -> orchestrator -> payments-investigator -> group-compliance-agent",
depth=3)
An unavailable delegate is a degradation, not a denial: alarm, set the flag, continue. The screening is deferred to a human. Refusing the whole request because a downstream screening service is down converts a partial capability loss into a total one.
8. Step 7 — the second guardrail pass, and the taint rule
if proposed and side_effecting:
derived_from = {d.doc_id for d in clean}
if derived_from & tainted_sources and not request.approvals:
deny(Layer.GUARDRAILS, "taint-rule",
f"side-effecting {proposed} derived from retrieved content "
f"{sorted(derived_from & tainted_sources)} without human approval")
The rule in one line: side-effecting + tainted + unapproved → refuse.
The reasoning is a bound on the strongest attacker rather than a filter on the likely one. Assume they fully control a retrieved document and the scanner misses entirely. Then:
| They ask for | They get |
|---|---|
| a read | the read — they already had that content |
| a write | a request in front of a named human, with the source document attached |
| an irreversible release | the same, plus a second human |
The ceiling is a human decision. Not prevention — prompt injection is not solved — but the difference between an automated exploit and a social-engineering attempt against somebody looking at an evidence record.
Three details that make it work rather than merely sound good:
It keys on the side-effect class, not the tool name. A read from tainted content is fine. Adding a tool without a declared side-effect class must be impossible; that is what the contract registry is for.
Approval must be genuine. The human sees the action and the source. A dialog that says only "release PMT-771?" has relocated the attack, not stopped it.
Taint propagates. In this lab it is a set of doc ids; in a real system it must survive summarization, caching and memory writes, which is the hard engineering (Phase 11).
9. Step 8 — the gateway, and why its checks run after a block
if proposed:
gateway_denials = self._gateway_checks(request, proposed, value) # ← ALWAYS
for layer, control, reason in gateway_denials:
deny(layer, control, reason)
if not blocked(): # ← only EXECUTION
…execute…
This is the ordering decision that surprises reviewers, and the argument is measurement.
The natural implementation short-circuits: something already denied, so skip the rest. It is faster, it is what every request-handling framework does, and it makes the defence-depth number a lie. If the taint rule blocked at step 7 and the gateway never evaluated, you cannot report "two independent layers refused" — you have one denial and an untested control. Attack case A-01 reads depth 1 instead of 2, and the platform's most important security claim is unmeasurable.
So the checks always run; only the execution is gated. The cost is a few microseconds of policy evaluation on requests that were going to fail anyway, and the benefit is that "two layers refused" is a fact rather than an inference.
The checks themselves return every failure, not the first:
def _gateway_checks(self, request, tool, value):
contract = TOOL_CONTRACTS.get(tool)
if contract is None:
return [(ACTION_GATEWAY, "unknown-tool", f"{tool} is not registered")] # ← except here
if contract["side_effect"] != "read" and not request.idempotency_key:
out.append((ACTION_GATEWAY, "idempotency", …))
if value >= threshold:
forbidden = {user_id, agent_id} | set(chain)
approvers = {a for a in request.approvals if a not in forbidden}
if len(approvers) < 2:
out.append((ACTION_GATEWAY, "dual-control", …))
return out
Unknown tool returns immediately because nothing further can be checked about a contract that does not exist. Everything else accumulates: a caller who fixes one problem and resubmits should not discover the second one on the next round trip.
The forbidden set is worth staring at. It excludes the requesting user, the acting agent, and
every agent in the chain. Without the chain, self-approval through a delegated agent works — the
user delegates to an orchestrator, the orchestrator's identity appears in the approvals, and the
count reaches two. That hole is invisible from inside the gateway, because from there the two
approver strings are simply different.
Then the execution path:
if not self.core_banking_available:
deny(Layer.INTEGRATION, "circuit-open", "…the action was deferred", blocking=False)
degraded_by_dependency = True
elif key and key in self._idempotency:
emit("action", outcome="replayed", reference=self._idempotency[key], …)
else:
reference = f"REF-{len(self.executed) + 1:04d}" # derived, not random
self.executed.append((key, proposed))
self._idempotency[key] = reference
if request.approvals: emit("approval", approvers=…, rationale=…)
emit("action", outcome="success", reference=reference, …)
An open circuit is a non-blocking denial plus a degradation. Blocking here would report a dependency outage as a policy denial, which sends the operator to the policy engine.
The reference is derived from len(self.executed), not uuid4(). Two independently constructed
platforms replaying the same run produce the same references and therefore the same audit chain —
which is what makes §12's determinism test possible.
10. Step 9 — four outcomes
if blocked():
outcome = ESCALATED if any(d.blocking and d.control in ("dual-control", "taint-rule")
for d in denials) else DENIED
elif self.degradation.level > 0 or degraded_by_dependency:
outcome = DEGRADED
ESCALATED before DENIED because those two controls describe a request that a human can still
approve. DENIED is terminal; ESCALATED opens a workflow. Conflating them means either your
approval queue is empty (escalations recorded as denials, so nobody is asked) or your denial rate is
meaningless.
DEGRADED covers two different causes — a ladder rung engaged, or a dependency deferred — and both
mean "answered, but not the full service". Keep it out of your availability numerator and your error
budget is measuring something true.
11. Step 10 — the evidence check
required = {"session", "policy_decision"}
if outcome in (COMPLETED, DEGRADED):
required |= {"retrieval", "inference", "execution_step"}
if any action:
required.add("action")
if any action value >= dual_control_threshold:
required.add("approval")
missing = tuple(sorted(required - present))
return not missing, missing
Two properties.
The contract depends on the outcome. A denied run has no inference to record and requiring one would make every correct denial look like an evidence failure. The evidence check runs after the outcome for this reason.
Missing artifacts are named. evidence_complete=False sends somebody hunting.
evidence_missing=('approval',) sends them to the approver. It costs one line and it is the
difference between a useful control and a red light.
What the check cannot do: verify that an artifact is true. Presence is mechanically checkable; truth is not. That is why independent validation exists (Phase 15) and why the ORR has a human panel (Phase 16).
12. Step 11 — telemetry and the hash chain
def _chain(self, artifacts):
head = self.audit_chain[-1] if self.audit_chain else "0" * 64
for artifact in artifacts:
material = json.dumps(artifact, sort_keys=True, separators=(",", ":"), default=str)
head = hashlib.sha256((head + material).encode()).hexdigest()
self.audit_chain.append(head)
sort_keys=True and fixed separators are not style. A chain computed over a non-canonical encoding
verifies only against the machine that wrote it — a different Python version, a different dict
insertion order, a different JSON library, and every historical head is unverifiable. Canonicalize,
or do not chain.
The chain gives you tamper evidence, not tamper prevention. Anyone who can rewrite the store can recompute the chain. It becomes real when a head is published somewhere the platform cannot reach — a WORM store, a different trust domain, a regulator's inbox.
13. The defence-depth harness
@dataclass(frozen=True)
class AttackCase:
case_id: str
description: str
build: Callable[[], Tuple[AIPlatform, Request]]
must_deny: bool = True
min_depth: int = 1
build is a thunk rather than a constructed platform because each case needs a fresh platform:
shared idempotency state between cases makes case N's result depend on case N−1's, and a suite whose
results depend on ordering is a suite that will one day pass for the wrong reason.
The classification:
if case.must_deny and not denied: fail("the attack was NOT denied")
elif case.must_deny and depth < min_depth: fail(f"denied by {depth} layer(s); {min_depth} required")
elif not case.must_deny and denied: fail("a legitimate request was denied")
else: pass
The third branch is the one most suites omit. A harness of attacks only can be satisfied by a platform that denies everything, and a platform that denies everything passes a security review and fails a business. Control cases keep the measurement honest.
The seven cases in the demo — six attacks and a control:
| # | Case | Required depth | Denied by |
|---|---|---|---|
| A-01 | injected instruction in a retrieved document proposes a release | 2 | guardrails + action gateway |
| A-02 | irreversible release with one approver | 1 | action gateway |
| A-03 | the agent approving its own action | 1 | action gateway |
| A-04 | a suspended agent | 1 | control plane |
| A-05 | a stale evaluation | 1 | control plane |
| A-06 | no idempotency key on an irreversible action | 1 | action gateway |
| A-07 | (control) the legitimate investigation | must not deny | — |
14. The chaos suite
@dataclass(frozen=True)
class ChaosCase:
case_id: str
failure: str
expected_outcome: Outcome
expected_alarm: str
expected_denial_layer: Optional[Layer]
build: Callable[[], Tuple[AIPlatform, Request]]
The three expected_ fields are the design under test. Writing them before running the case is
what makes this chaos engineering rather than breaking things: if you can predict the degradation you
understand the design, and if you cannot, the case has already taught you something.
Checking all three matters because each catches a different silent failure:
- outcome — the platform did something other than what you designed;
- alarm — it degraded correctly and told nobody, so your operators learn from a customer;
- denial layer — the outcome was right for the wrong reason, which is the one that survives refactoring and bites later.
15. The degradation ladder, enforced
LADDER = (
Rung("disable-rerank", "skip the cross-encoder reranker", is_control=False, user_visible=False),
Rung("smaller-model", "route to the small model", is_control=False, user_visible=True),
Rung("cache-only", "serve only from the semantic cache", is_control=False, user_visible=True),
Rung("read-only", "refuse side-effecting tools", is_control=False, user_visible=True),
Rung("reject", "refuse new work", is_control=False, user_visible=True),
)
def validate_ladder(ladder=None):
ladder = LADDER if ladder is None else ladder
offenders = [r.name for r in ladder if r.is_control]
if offenders:
raise LadderError(f"{offenders} are controls and must never be on the "
f"degradation ladder; quality may degrade, safety may not")
Three placement decisions:
Called from AIPlatform.__init__. A bad ladder fails at start-up. Discovering it when the rung is
engaged means discovering it during the incident.
The default resolves at call time. def validate_ladder(ladder=LADDER) binds the tuple that
existed at import; the point is to check the ladder in force now.
The message names the offenders. A reviewer sees an answer, not a puzzle.
Note also that the ladder is ordered by increasing user impact and that rung 1 is invisible to users. Descend fast, ascend slowly (Phase 14): jumping down two rungs at once is cheap, and climbing back up one rung at a time is what stops oscillation.
16. Determinism, and why it is a design requirement here
Every source of nondeterminism is injected or derived:
| Would be | Is |
|---|---|
time.time() | an injected integer counter |
| a real model call | an injected ModelFn |
uuid4() reference | f"REF-{len(self.executed) + 1:04d}" |
| float dollars | integer micro-USD, divided last |
| set iteration in output | sorted(...) |
In a composition this is not merely convenient. A chaos suite asserts "with the delegate down, the outcome is DEGRADED and the alarm contains 'screening deferred'". If a run can differ between executions, that assertion becomes flaky, and a flaky security assertion is deleted within a month — by a reasonable engineer, for reasonable reasons. Determinism is what makes the safety properties enforceable in CI, and enforceability is the entire value.
The audit-chain test is the sharpest expression: two independently constructed platforms, given the same run, produce byte-identical chains. That is only possible because nothing in the path reaches for the wall clock or a random number.
17. Failure modes, catalogued
| Failure | Symptom | Where the design stops it |
|---|---|---|
| chain replaced at a hop | audit record names an agent | delegate_to appends; the artifact carries the whole chain |
| delegation cycle | recursion limit, or an infinite loop | delegate_to raises, naming the chain |
| fallback breaches residency | nothing, until the primary fails | the residency gate is inside _route |
| a skipped-route reason logged as a denial | a successful fallback reports as failed | only an exhausted list denies |
| short-circuit on first denial | defence depth under-counted | gateway checks always run |
| barrier removal treated as blocking | availability SLI measures barrier policy | blocking=False |
| circuit-open treated as blocking | dependency outage reported as policy denial | blocking=False + degraded |
| escalation recorded as a denial | the approval queue is empty | outcome checks the control name |
| an artifact without the trace id | approval cannot be linked to action | emit() stamps it in one place |
| a denial with no record | indistinguishable from a control that never ran | policy_decision emitted either way |
| classification checked before barrier | MNPI leak | barrier check first |
| non-canonical JSON in the chain | historical heads unverifiable | sort_keys=True, fixed separators |
| control added as a ladder rung | months of silence, then an incident | is_control + construction-time check |
| a control suite of attacks only | a platform that denies everything passes | must_deny=False cases |
18. References
- Leveson, N. Engineering a Safer World. MIT Press, 2011 — accidents as unsafe interactions between components that each meet their requirements.
- Basiri, A. et al. "Chaos Engineering." IEEE Software 33(3), 2016 — hypothesis before injection.
- Greshake, K. et al. "Not What You've Signed Up For." AISec 2023, arXiv:2302.12173 — indirect prompt injection.
- Debenedetti, E. et al. "AgentDojo." NeurIPS D&B 2024, arXiv:2406.13352 — measuring agent defences under attack.
- OWASP Top 10 for LLM Applications (2025). https://genai.owasp.org/
- MITRE ATLAS. https://atlas.mitre.org/
- Google, Building Secure and Reliable Systems, ch. 8 and 19. https://sre.google/books/building-secure-reliable-systems/
- Beyer, B. et al. Site Reliability Engineering, ch. 22. https://sre.google/books/
- Haber, S. & Stornetta, W. S. "How to Time-Stamp a Digital Document." Journal of Cryptology, 1991 — the hash chain.
- Rescorla, E. RFC 8785, JSON Canonicalization Scheme — why
sort_keysis not style.