« 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

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:

AlgorithmResultUse
deny-overridesany deny winsthe default for anything regulated
permit-overridesany permit winsrarely correct; occasionally right for break-glass
first-applicablethe first matching rule winsorder-dependent — avoid
only-one-applicableerror if more than one matchesrequires 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:

ConditionIf violatedWhy it is separate
The lease existsevaluate
now - issued_at < ttlevaluatebounds staleness
Not high-impactevaluateconsequential actions are never cached
Agent not revokedevaluatethe kill switch
lease.policy_version == active.versionevaluatea 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:

ApproachFreshnessAvailabilityWhen
Replicate everythingminutesexcellentsmall, slow-changing fact sets
Fetch on demand, cachesecondsthe source is now a dependencylarge fact sets, tolerant latency
Replicate + invalidation eventssecondsgoodthe 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:

ObligationThe PEP must
mask:account_numberredact before returning
require:second_approvernot proceed until a second human approves
audit:high_valueemit to the immutable audit stream, not just the log
notify:ownertell the agent's owner this happened
limit:1000000cap 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:

SignalDetectsFalse positives
Tool-call distribution vs the agent's own baselinean agent doing something newlegitimate new use case
Call rate vs baselinea loop, or an injected batcha genuine spike in demand
Denial rateprobing, or a broken agenta policy change
Time-of-day deviationcredential misusea release weekend
Novel argument shapesinjectiona new upstream data format
Chain depth vs baselinerunaway delegationa legitimately deeper task

The disciplined design:

  1. Baseline per agent, not globally. Agents differ enormously, and a fleet-wide baseline flags the unusual agent rather than the unusual behaviour.
  2. Score continuously, threshold explicitly, and use two thresholds — one that restricts writes, one that stops everything.
  3. 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.
  4. 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

OperationCostNotes
Rule match, no condition~100 nstuple membership
Rule match with a condition1–10 µsdepends entirely on the condition
Full evaluation, 100 rules~0.5 mslinear, no short-circuit
Full evaluation, 1,000 rules~5 msstarting to matter on a per-step path
Lease hit~1 µsa dict lookup and two comparisons
Bundle verify + parse10–50 msonce per activation, off the request path
Remote PDP round trip5–20 msplus 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

FailureSymptomRoot causeFix
Everything denied after a deploytotal outagea rule with an empty facet matching everythingbundle validation at build time
Everything allowedsilentunconditional allow-everything rulethe same validation
Policy latency = platform latencyp99 spikesremote PDP on the request pathsidecar or library
Policy outage = platform outagecorrelated failurefail-shutfail-static + hard stop
Stale policy never alarmsdiscovered in an audita rejected push reset the clockmeasure from last successful activation
No policy after a restartpods fail-shut on rolloutno disk persistencepersist the bundle
Emergency rule takes a minuteslow responseleases not invalidated on activationversion check in the lease
Suspension takes 3 minutestoo slow for a payment agentno kill switchpush channel + revoked set
Kill switch races trafficintermittent allows after suspendleases dropped but not blockedthe revoked set
Fix applied, still denieduser distrustcached deniescache allows only
Agent plans around a hidden toolrepeated denialsdiscovery approximates policyprobe the real engine
Prompt cache missescost spikeunstable tool orderingsort
Fleet down on a late eval jobavailability incidentbinary posturegraduated findings
Anomaly score disabledthe control silently goneuntuned false positivesmeasure before blocking
Cannot reconstruct a decisionaudit findingno policy version in the recordput it there from day one
Policy rolled back by a replayquiet loss of a controlno monotonicity checkreject older bundles