Skip to content

tutorial

Chapter 2 of 6

Chapter 2 — Declare the risk, in a table

by Rod Rivera Published

Write down what each action demands, keyed by the action rather than by the caller, so that adding a dangerous tool cannot silently inherit a weak check.

The requirement is a property of what the action does if it succeeds. Not of who is calling, not of which skill they arrived through, not of how far into the call they are.

For a reissue, what it does depends on the destination:

REQUIRED_TIER = {
    AddressProvenance.ON_FILE: "medium",
    AddressProvenance.STATED:  "high",
    AddressProvenance.UNKNOWN: "high",
}

Three rows, in cardpolicy/guard.py. Posting a card to an address the bank has held for six years is a genuinely different act from posting one to an address supplied during the call, and the table is where that judgement is written down once instead of being re-argued at every call site.

Why a table and not an if

Scattered conditionals are how the twelfth call site ends up disagreeing with the other eleven. A table is greppable, reviewable in one screen, and diffable — when someone loosens a requirement, the diff says so in one line rather than hiding inside a refactor.

The default is the strict one

Notice UNKNOWN. It exists because the alternative to representing “we do not know where this came from” is pretending we do, and a system that cannot express doubt resolves doubt in favour of proceeding.

The same instinct governs how a tier is read out of memory:

def _rank(tier: object) -> int:
    if isinstance(tier, str):
        return _TIER_RANK.get(tier.strip().casefold(), 0)
    return 0

Memory is text. It may be absent on the first turn, stale from an older build, or something a model invented. Every one of those resolves to rank 0 — no verification at all — rather than raising, because a guard that throws on unexpected input is a guard that gets wrapped in a bare except within a month.

make policy exercises this directly:

✓ auth_tier='admin' is refused, not interpreted
✓ auth_tier='' is refused, not interpreted
✓ auth_tier='3' is refused, not interpreted
✓ auth_tier='high ish' is refused, not interpreted

Note '3'. A numeric string is not quietly read as a rank. And note what is not on that list — casing:

✓ auth_tier='HIGH' is the same tier as 'high'

ASR casing is not a security signal, and treating it as one produces a system that refuses the right caller for the wrong reason. Normalising case is not the same as being permissive; the deliberate part is knowing which differences are noise and which are the question.

Where the requirement is not declared

One place, deliberately: the model cannot write it. In memory.yml,

auth_tier:
  type: text
  llm_settable: false

A model that can set its own authorisation level is not authorised; it is self-certifying.