« Phase 09 · Warmup · Track Overview
Deep Dive — Mechanisms and Failure Modes
The warmup established what the pieces are. This takes them apart: how evaluation actually works, what breaks, and what the fix costs.
Table of Contents
- 1. Evaluation, mechanically
- 2. Rule matching and the wildcard bug
- 3. Combining algorithms in full
- 4. Bundle integrity: what the signature actually covers
- 5. Distribution mechanics
- 6. The staleness state machine
- 7. Lease invalidation, exhaustively
- 8. The kill switch and its race
- 9. Discovery: the five filters and their order
- 10. Entitlement data: the second staleness problem
- 11. Obligations
- 12. Anomaly scoring, honestly
- 13. Performance
- 14. Failure modes
1. Evaluation, mechanically
A single evaluation is four steps:
1. MATCH every rule against the request → matched[]
2. PARTITION matched into denies[] and allows[]
3. COMBINE deny-overrides: denies ? DENY : allows ? ALLOW : DENY
4. RECORD effect, reason, deciding rule, ALL matched, version, timestamp
Step 1 is the whole cost. Steps 2–4 are bookkeeping.
The design choice worth naming: step 1 does not short-circuit. A naive implementation returns as soon as it finds an allow, which is faster and produces an order-dependent rule set. The extra work is what buys reviewability.
The second design choice: step 3's default is DENY, and it appears twice. Once for "some rule
denied" and once for "no rule matched at all". Those are different reasons and the record should say
which — denied by deny-cross-tenant and no matching rule (default deny) lead to completely
different debugging.
2. Rule matching and the wildcard bug
A rule has facets. The semantics that everyone converges on:
- an empty facet means "any";
- a populated facet must match;
- all populated facets must match — conjunction, not disjunction.
The conjunction is the one to get right. Written as any, a rule with actions=("crm.read",) and
tenants=("retail",) fires for a wholesale crm.read, which for a DENY is over-blocking and for an
ALLOW is a hole.
And the empty-means-any convention has a specific trap: an ALLOW with every facet empty and no
condition matches every request. It is one forgotten actions= away, and it is completely silent —
the platform keeps working, better than before. Validate against it at bundle-build time. An
unconditional deny-everything is legitimate; it is the panic bundle.
The wildcard:
def _action_matches(pattern: str, action: str) -> bool:
if pattern == action:
return True
if pattern.endswith(".*"):
return action.startswith(pattern[:-1]) # keep the dot: "payments."
return False
pattern[:-1] strips the * and keeps the ., so payments.* becomes the prefix payments..
Strip the dot too and pay.* matches payments.release, which is a cross-namespace grant that
looks correct in review. This is a two-character bug with a genuine security consequence, and it is
worth a test.
3. Combining algorithms in full
XACML defines several; you will meet four:
| Algorithm | Result | Use |
|---|---|---|
| deny-overrides | any deny wins | the default for anything regulated |
| permit-overrides | any permit wins | rarely correct; occasionally right for break-glass |
| first-applicable | the first matching rule wins | order-dependent — avoid |
| only-one-applicable | error if more than one matches | requires disjoint rules; brittle at scale |
Why deny-overrides and not first-applicable, stated as a property rather than a preference:
Under deny-overrides, the meaning of a rule set is independent of rule order. Under first-applicable, it is not.
Order-independence is what makes rule sets composable. Team A's rules and team B's rules can be concatenated in either order with the same result, which is what lets a central security team append rules to a bundle a product team authored.
The cost is that you cannot express "this exception overrides that prohibition" by ordering. You express it by making the prohibition's condition exclude the exception — which is more verbose and much more legible six months later.
Deterministic reporting. When several denies match, report one, deterministically — the first in bundle order. Not "whichever the set iterator produced". The same request must always name the same rule, or your alert grouping fragments and your incident timeline lies.
4. Bundle integrity: what the signature actually covers
def digest(self) -> str:
material = json.dumps({...}, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(material.encode()).hexdigest()
Three details:
sort_keys=True — otherwise two semantically identical bundles hash differently depending on
dict insertion order, and the signature is a function of serialization luck.
separators=(",", ":") — no incidental whitespace. The same problem as canonical JSON in
Phase 08: if the bytes you hash are not the bytes you
agreed to hash, the verifier and the signer will eventually disagree.
hmac.compare_digest, never == — string comparison short-circuits on the first differing byte,
and the timing difference leaks the signature one byte at a time. In this specific case the attacker
would need to forge a bundle signature to exploit it, which is a high bar; use the constant-time
compare anyway, because "this one is probably fine" is how the habit erodes.
And the honest limitation, which is worth stating out loud in a design review: a digest over Python callables covers their presence, not their content. You can swap a condition function's body and the digest is unchanged. That is not a flaw in the hashing; it is the reason production policy is text. Rego and Cedar policies are strings, the string is what you sign, and signing becomes meaningful.
5. Distribution mechanics
Production distribution (OPA's bundle plugin is the reference implementation):
1. POLL GET the bundle URL, with If-None-Match: <etag>
2. 304? nothing changed — reset the "last successful check" clock
3. 200? download, verify signature, parse, validate
4. ACTIVATE swap the pointer atomically
5. PERSIST write to disk, so a restart starts from known-good
6. REPORT publish status: version, activation time, last error
Steps the lab omits and production needs:
- ETags — so a 30-second poll on an unchanged bundle costs nothing.
- Disk persistence — without it, a pod restarting while the source is down has no policy, and fail-static degenerates to fail-shut at exactly the wrong moment.
- Status reporting — the control plane needs to know which PEPs are on which version. "We activated the emergency rule" is only true when every PEP reports it.
- Delta bundles — for very large data sets; irrelevant below a few megabytes.
Rollback resistance. Reject a bundle older than the active one. Without it, an attacker who can replay yesterday's bundle can undo this morning's emergency restriction without forging anything — the old bundle's signature is genuine. Compare on a monotonic field the signer controls, not on arrival time.
6. The staleness state machine
successful activation
┌───────────────────────────────────────┐
│ │
▼ age ≥ alarm age ≥ hard_stop
┌─────────┐ ────────────────► ┌───────┐ ──────────────► ┌──────────────┐
│ FRESH │ │ STALE │ │ HARD-STOPPED │
│ serving │ ◄──────────────── │serving│ ◄────────────── │ REFUSING │
└─────────┘ successful └───────┘ successful └──────────────┘
activation activation
Two invariants that are easy to break:
Age is measured from the last successful activation, not the last attempt. A source returning 403 forever must age. If a rejected offer resets the clock, the system looks healthy while running on a bundle from last Tuesday — which inverts the purpose of the alarm.
STALE still serves. That is the whole point. A design where "stale" means "refuse" is fail-shut wearing a different label.
Choosing the thresholds is a real judgment, and the honest framing is a question: what is the longest a policy change could go un-applied without material harm? For a read-only agent fleet, an hour. For agents that can move money, minutes — because the change you are worried about is the one that suspends an agent that is misbehaving right now.
7. Lease invalidation, exhaustively
A lease may be reused only if all five hold:
| Condition | If violated | Why it is separate |
|---|---|---|
| The lease exists | evaluate | — |
now - issued_at < ttl | evaluate | bounds staleness |
| Not high-impact | evaluate | consequential actions are never cached |
| Agent not revoked | evaluate | the kill switch |
lease.policy_version == active.version | evaluate | a new bundle takes effect now |
The last one is the one that is usually missing, and its absence is subtle: the platform works, the tests pass, and a policy push takes effect one TTL after activation. In the window, decisions are made under a version that has been superseded, and the decision records name it — so an auditor reading the log sees the platform enforcing a retired policy and cannot tell whether that was a bug or a rollback.
What the fingerprint must exclude. The key is the stable part of the request: subject, action, resource. Not the tick, not the anomaly score. Include those and every request is a miss and the lease is decorative.
Which sounds like a hole — the anomaly score is a posture signal, and we just excluded it from the key. It is, and the answer is the same as everywhere else in this phase: the TTL is the bound. A score that spikes takes up to one TTL to be reflected, unless something explicitly invalidates. If that is too slow for your risk appetite, the fix is not a bigger key; it is a shorter TTL for the affected class of action, or a push invalidation on the score crossing a threshold.
Cache allows, never denies. A cached deny extends the time between "the operator fixed it" and "it works again" by a TTL. Users experience that as the system being broken after the fix, which is the single most corrosive thing an access system can do to its own credibility.
8. The kill switch and its race
The naive kill switch drops the live leases. It has a race:
t=0.000 operator clicks suspend
t=0.001 registry updated: agent → SUSPENDED
t=0.002 revocation message sent
t=0.003 PEP-7 has not received it yet
t=0.003 a request arrives at PEP-7; the lease was dropped, so it re-evaluates...
t=0.004 ...against a registry replica that has not caught up → ALLOW → new lease
t=0.005 revocation arrives, drops the lease it just created
t=0.006 another request → re-evaluate → still stale → ALLOW → new lease
Dropping leases makes revocation fast; it does not make it stick. The second half is a revoked set at the enforcement point: while an agent is in it, no lease is created, so every request goes to a control plane that will shortly be correct — and once it is, the answer flips and stays flipped.
The properties that make it work:
- Idempotent. Ten revocation messages behave like one.
- Fail-safe under duplication, not under loss. A duplicate is harmless; a dropped message is a silent failure. So the poll-based path must eventually reach the same conclusion — the kill switch is an accelerator on top of a correct slow path, never a replacement for it.
- Authenticated. A forged revocation is a denial of service against your own platform. Sign it like a bundle.
- Reversible. Reinstatement must be as fast as revocation, or operators will hesitate to use the kill switch — and a control people hesitate to use is not a control.
9. Discovery: the five filters and their order
for tool in tools.all(): # sorted → stable output
if tool.tool_id not in record.permitted_tools: continue # 1 registry
if reads_only and tool.side_effect is not READ: continue # 2 posture
if tool.tenants and subject.tenant not in ...: continue # 3 tenancy
if rank(tool.classification) > allowed_rank: continue # 4 clearance
if not set(tool.required_scopes) <= held: continue # 5 scopes
if not engine.evaluate(probe).allowed: continue # 6 policy
The order is cheapest-first, and the policy probe is last because it is the expensive one. But the important property is not performance — it is that the last filter is a real policy evaluation, not a reimplementation of policy in the discovery path.
The tempting shortcut is to approximate policy in discovery ("show tools whose classification is below the agent's ceiling") and only enforce properly at call time. It drifts immediately: someone adds a rule that denies a tool during a freeze window, discovery does not know, and the agent plans around a tool it will be refused. Probe the real engine.
Stable ordering matters more than it looks. The tool list goes into a prompt. A list whose order varies between requests makes the model's behaviour vary between requests for reasons unrelated to the task, and it destroys prompt-cache hit rates (Phase 04).
10. Entitlement data: the second staleness problem
Policy answers what rules apply. Rules need facts: which accounts this user may see, which group they are in, what their limit is. Those facts live in core banking, the entitlement service, the HR feed.
They cannot be fetched synchronously per decision, for exactly the reason policy cannot. So they are replicated to the enforcement point — and now you have a second staleness problem, with a worse character: entitlement facts change faster than policy, and the change that matters most is revocation. Somebody left the firm. Somebody moved desks and must no longer see the advisory book.
Three approaches:
| Approach | Freshness | Availability | When |
|---|---|---|---|
| Replicate everything | minutes | excellent | small, slow-changing fact sets |
| Fetch on demand, cache | seconds | the source is now a dependency | large fact sets, tolerant latency |
| Replicate + invalidation events | seconds | good | the right answer, and the most work |
The third is what a real bank ends up with: a bulk replica for availability, plus an event stream for revocations, because revocation is the one direction where staleness is unsafe. Note the asymmetry — a grant that takes five minutes to propagate is an inconvenience; a revocation that takes five minutes is an incident. Design the fast path for revocations only, and the slow path handles the rest.
11. Obligations
An allow is often conditional on the enforcement point doing something:
| Obligation | The PEP must |
|---|---|
mask:account_number | redact before returning |
require:second_approver | not proceed until a second human approves |
audit:high_value | emit to the immutable audit stream, not just the log |
notify:owner | tell the agent's owner this happened |
limit:1000000 | cap the value of the action |
Obligations are how policy expresses "yes, but". Without them the rule set bifurcates into allow/deny and the "but" migrates into application code, where it is invisible to review.
The rule that makes them safe: an unrecognized obligation is a DENY. A PEP that receives
mask:account_number and does not know how to mask must refuse, not proceed unmasked. This inverts
the usual "ignore unknown fields" extensibility instinct, and it has to: an obligation the enforcer
silently drops is a control that silently vanishes.
12. Anomaly scoring, honestly
The anomaly score is the softest thing in this phase and gets waved at in design reviews. What it can actually be built from:
| Signal | Detects | False positives |
|---|---|---|
| Tool-call distribution vs the agent's own baseline | an agent doing something new | legitimate new use case |
| Call rate vs baseline | a loop, or an injected batch | a genuine spike in demand |
| Denial rate | probing, or a broken agent | a policy change |
| Time-of-day deviation | credential misuse | a release weekend |
| Novel argument shapes | injection | a new upstream data format |
| Chain depth vs baseline | runaway delegation | a legitimately deeper task |
The disciplined design:
- Baseline per agent, not globally. Agents differ enormously, and a fleet-wide baseline flags the unusual agent rather than the unusual behaviour.
- Score continuously, threshold explicitly, and use two thresholds — one that restricts writes, one that stops everything.
- Make the inputs visible in the decision record. "anomaly 0.9" is unactionable. "anomaly 0.9: tool distribution deviated, 14 denials in 60s" is a starting point.
- Measure the false-positive rate before you wire it to a block. A score that fires wrongly twice a week will be disabled within a month, and the disabling will not be documented.
If you cannot do (4), wire it to an alarm rather than a block, and say so. A soft signal presented as a hard control is worse than no control, because it creates confidence that is not earned.
13. Performance
| Operation | Cost | Notes |
|---|---|---|
| Rule match, no condition | ~100 ns | tuple membership |
| Rule match with a condition | 1–10 µs | depends entirely on the condition |
| Full evaluation, 100 rules | ~0.5 ms | linear, no short-circuit |
| Full evaluation, 1,000 rules | ~5 ms | starting to matter on a per-step path |
| Lease hit | ~1 µs | a dict lookup and two comparisons |
| Bundle verify + parse | 10–50 ms | once per activation, off the request path |
| Remote PDP round trip | 5–20 ms | plus availability coupling |
Linear evaluation is fine to about a thousand rules. Past that:
- Index by action. Most rules name specific actions; a dict from action to candidate rules cuts the scan by an order of magnitude and preserves the semantics exactly.
- Partial evaluation. OPA's approach: specialize the policy against the parts of the input known in advance, producing a residual policy that is much cheaper per request.
- Split the bundle by PEP. The retrieval enforcement point does not need the payment rules.
What not to do: short-circuit on the first allow. It buys a factor of two and costs order-independence, which is the property the whole design rests on.
14. Failure modes
| Failure | Symptom | Root cause | Fix |
|---|---|---|---|
| Everything denied after a deploy | total outage | a rule with an empty facet matching everything | bundle validation at build time |
| Everything allowed | silent | unconditional allow-everything rule | the same validation |
| Policy latency = platform latency | p99 spikes | remote PDP on the request path | sidecar or library |
| Policy outage = platform outage | correlated failure | fail-shut | fail-static + hard stop |
| Stale policy never alarms | discovered in an audit | a rejected push reset the clock | measure from last successful activation |
| No policy after a restart | pods fail-shut on rollout | no disk persistence | persist the bundle |
| Emergency rule takes a minute | slow response | leases not invalidated on activation | version check in the lease |
| Suspension takes 3 minutes | too slow for a payment agent | no kill switch | push channel + revoked set |
| Kill switch races traffic | intermittent allows after suspend | leases dropped but not blocked | the revoked set |
| Fix applied, still denied | user distrust | cached denies | cache allows only |
| Agent plans around a hidden tool | repeated denials | discovery approximates policy | probe the real engine |
| Prompt cache misses | cost spike | unstable tool ordering | sort |
| Fleet down on a late eval job | availability incident | binary posture | graduated findings |
| Anomaly score disabled | the control silently gone | untuned false positives | measure before blocking |
| Cannot reconstruct a decision | audit finding | no policy version in the record | put it there from day one |
| Policy rolled back by a replay | quiet loss of a control | no monotonicity check | reject older bundles |