Policies & rules
A policy pack is what the rules are — you write it. A profile is which controls must exist and what must be recorded — the standard writes it. Getting these two confused makes every later sentence wrong, so they are never the same file.
The one rule about rules: they are data, never code
Declarative rules, a fixed comparator vocabulary, no expression language,
nothing to eval. This is a security boundary rather than a style preference: an
executable policy pack downloaded from a registry is remote code execution wearing a
governance hat, and the risk arrives the moment packs are shared between deployments —
which is where any successful policy format goes.
Anything a pack cannot express is a missing engine, and the answer is a new engine — code, reviewed as code, on a gated path — not an escape hatch.
The pressure on this will be constant. Every sufficiently expressive policy format is eventually asked for a way to run a small snippet, and each individual request is reasonable. The answer that holds is the one above.
What a pack looks like
tesoro init writes tesoro.yaml (which profile, which pack, where evidence
goes) and policies/default.yaml — the rules, which are yours to edit. JSON is accepted
everywhere YAML is.
version: 1
name: default
config:
treasury: # the external channel
balance_usd: "500"
per_transaction_usd: "10" # a ceiling, never accumulates
daily_usd: "50" # cumulative
monthly_usd: "300"
per_vendor_30d_usd: "100"
per_resource_30d_usd: "50"
emergency_reserve_usd: "10"
velocity_60s: 10 # a rate, not a total
velocity_1h: 100
actions_per_day: 500 # a total. omit for no limit; 0 forbids everything
actions_per_month: 10000
treasury_internal: # tokens; overrides only what it names
per_transaction_usd: "0.04" # one run's token budget
daily_usd: "0.15"
rules:
- id: deny-sanctioned
priority: 0 # lowest number decides first
when:
vendor.sanctioned: true
then: REJECT
reason: sanctioned counterparty
- id: review-budget-exhausted
priority: 10
when:
budget.ok: false
then: REVIEW
reason: outside a budget envelope
Every amount is a string. A YAML number would have lost precision before anything read it, and the test vectors check exactly this.
The pack has a content hash, and it goes into every decision.
tesoro check prints it. A label can be reused across edited rules; a hash
cannot — which is why the Decision Record schema records the hash rather than the name, and why
every measurement stamps it.
The fixed vocabulary
Ten comparators
| Comparator | Means | Example |
|---|---|---|
eq · ne | equals, does not equal | vendor.id: {ne: acme} |
lt · lte | under, at most | amount_usd: {lte: "1.00"} |
gt · gte | over, at least | trust.score: {gte: 0.7} |
in · not_in | is / is not one of | budget.binding: {in: [balance, emergency_reserve]} |
between | inclusive range | amount_usd: {between: ["1.00", "5.00"]} |
contains | membership in a list value | vendor.tags: {contains: verified} |
eq, ne, in and not_in are null-safe; the
ordering comparators are not, because “is an unknown score greater than 0.7” has no honest
answer and inventing one is how the four-state rule gets broken.
Twenty facts a rule may read
Two facts for jurisdiction, and the second is the useful one.
vendor.jurisdiction is the value an operator declared — and it is
null for two different situations: nobody was asked, and an operator was
asked and does not know. vendor.jurisdiction_state separates them, with one of
undeclared, unknown or declared.
Nothing derives it. Not from the counterparty's address, not from a domain
suffix in its id, not from the settlement chain. Each of those correlates with jurisdiction and
none of them is jurisdiction — a .de domain is registrable from
anywhere. An absent jurisdiction is visibly absent and you can refuse on it; an inferred one is
a guess that looks exactly like a declared fact.
AEGS-0.1-CTRL-6a
makes that a requirement rather than a preference.
rules:
- id: unvetted-counterparty
priority: 1
when:
vendor.jurisdiction_state: undeclared
then: REVIEW
because: nobody has said where this counterparty is
priority is not optional here. Rules are evaluated in priority order and the first
match is terminal, so a rule without one sorts last and is reached only when nothing else
matched — which for a rule meant to catch unvetted counterparties is indistinguishable
from it not working. This example was run before it was published, and it fired for
undeclared and fell through for a declared LK.
| Group | Facts |
|---|---|
| the action | amount_usd resource purpose channel agent_id |
| counterparty | vendor.id vendor.sanctioned vendor.tags vendor.is_new vendor.settled_count vendor.jurisdiction vendor.jurisdiction_state |
| trust & risk | trust.score trust.flags risk.score risk.flags |
| value | roi.known roi.ratio roi.justified |
| budget | budget.ok budget.binding budget.headroom_usd |
The pack is validated at load, in full, or it is not loaded. An unknown fact name,
an unknown comparator or a verdict that is not one of the four is a startup error rather than a
surprise at run time — because a rule that never matches never validates, and a pack could
otherwise carry a verdict of MAYBE and look fine until the one request that touched it
arrived.
Derived facts: composition without execution
“amount over $100 and the vendor is new” needs no new measurement, only composition. That is a derived fact — still data, built from the same fixed comparators with three fixed connectives:
derived:
- name: risky_newcomer
all: # all | any | not
- amount_usd: {gt: "100"}
- vendor.is_new: true
rules:
- id: review-risky-newcomer
priority: 20
when:
risky_newcomer: true
then: REVIEW
What makes this safe is the closure: a pack that could introduce a fourth connective could introduce logic.
Envelopes: the part a per-payment cap cannot express
binding answers why was this refused and exists only on a
refusal; tightest answers what bites next and always exists.
An earlier version had only the first, under a heading meaning the second — so an approved decision
showed no envelope at all, blank precisely when somebody was checking headroom.
A rate counter is not a count envelope, and this box used to conflate them.
velocity_60s: 10 and velocity_1h: 100 bound the rate. They
permit 2,400 actions a day, and an agent pacing at 97 an hour is compliant with them forever.
actions_per_day and actions_per_month bound the total.
No product of a rate limit and a duration is ever compared against anything, so an hourly ceiling of 100 does not imply a daily ceiling of 2,400 — it implies none at all. Both kinds are count envelopes under AEGS-0.1-ENV-7, which has permitted them since 0.1; the only thing missing was a window longer than an hour.
Omit either key for no limit. 0 is not "no limit" — it forbids
every action. See what Tesoro does not do for
what a count envelope still does not catch.
Rules are evaluated in order, and the first match decides
By priority, lowest number first, with the rule id as the tiebreaker so two rules at
the same priority resolve the same way every time. tesoro report --html prints the
rules in evaluation order with each condition in plain terms, because the question anybody
actually has before a run is which rule is going to stop me — and a count of rules cannot
answer it.
Pri Rule When Then Because
0 deny-sanctioned vendor.sanctioned REJECT sanctioned counterparty
5 deny-over-balance not budget.ok and budget.binding REJECT insufficient balance
is one of balance, emergency_re…
10 review-budget-exhausted not budget.ok REVIEW outside a budget envelope
Profiles: what the standard requires, not what you want
A profile is a conformance contract. It says which controls must exist and what evidence must be emitted — and it never touches a verdict, which is asserted four different ways in the test suite. Switching profile changes what is scored, never what is decided.
| Level | Means |
|---|---|
OPTIONAL | the control may be absent entirely |
MUST_RECORD | the record must carry the field — a stated null satisfies it |
MUST_EXERCISE | the control must actually have run |
The distinction between the last two is load-bearing, and getting it wrong punished honesty:
an earlier scorer treated intentId: null as a failure. But a stated null is an
implementation saying “no intent was declared”, which is true, useful, and exactly what the
four-state rule asks for. MUST_RECORD needs the key present, not non-null.
| Profile | Requires | For |
|---|---|---|
none | nothing — but lists all 13 controls explicitly | an opt-out, not an empty contract |
aegs-1 | 5 exercised, 2 recorded, 6 optional | the baseline claim |
aegs-2 | 8 exercised, 1 recorded, 4 optional | the stricter claim |
none lists every control rather than being an empty file, so the shipped manifest is
the implementation's own answer to “which controls are defined” — and a profile that
extends another may only tighten, checked by a tool.
Two extension points, and why there are exactly two
Derived facts data
Composition of existing facts, in the pack. No gate needed, because nothing new is measured and nothing can execute.
Registered engines code
A genuinely new measurement. Code, reviewed as code, and gated at registration: reserved control names are refused, and a purity check rejects an engine that reads the filesystem, the network or the clock.
A registered engine can only narrow. That is not a rule it is asked to respect — the combination step takes the most severe verdict, so widening is unreachable rather than refused. A hostile engine can refuse things; it cannot approve anything.
Reference
Writing your own rules
The full pack format, both extension points, worked examples.
custom-policies.md →