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.
