Adapt Reeflex to your use case¶
The entire decision policy that reeflex-core evaluates is one Rego file:
reeflex-core/policy/reeflex.rego, backed by
one test file, reeflex-core/policy/reeflex_test.rego.
There is no plugin API, no DSL, no hidden config layer — you read the rules,
you edit the rules, opa test tells you whether you broke anything. That file
IS the product's decision surface. This guide shows three levels of change,
from smallest to largest, with every example verified against a real opa
test run before it went into this document (see the raw output inline
below — this guide does not ask you to trust an unverified snippet).
Nothing in this guide requires touching reeflex-core's Python. The engine
(app/opa.py) only ever asks OPA one question — data.reeflex.policy.decision
— and returns whatever comes back. Change the policy, not the engine.
1. How it works (short version)¶
Every request to POST /v1/decide carries an Action Envelope (3 risk axes +
verb + target + magnitude + cumulative state). The base policy evaluates twelve
rules — R0–R11, thirteen rule ids (R5 reports under two; R10 is split into
R10a, a deny, and R10b, two budgets reported under R5's id) — against those fields
and returns exactly one decision object: allow, deny, or
require_approval. Precedence is explicit and total —
deny > require_approval > allow — so for any input exactly one Rego block
matches and no two rules can disagree.
How a verdict is reached
draws the whole ladder, every rule by its id, in one picture; read it before
you edit anything below.
Environment matters. R2, R3, R6 and R7 are gated on
production. Indev,staging, or any other environment, an irreversible / broad / systemic action is not held or denied by them — only R1 (read allow), R5 (cumulative budgets), and R4 (default allow) apply. Settarget.environmentaccordingly; the stricter behavior is intentional for production only.R0 is not in that list, and it is not a rule you can trip on purpose. It fires when the verdict R2, R3 or R6 would have produced rests on a value core filled in rather than one your adapter declared — an axis you omitted, an environment outside the enum. It converts that refusal into a resolvable hold under
reeflex.policy/unclassified_action. It can only ever soften a refusal; it never turns an allow into anything.R6 also needs a declaration. It holds an irreversible production action on a declared production asset at any cardinality (SPEC §4.3) — which is what closes the
rm /srv/prod/db.sqlitehole R2'sbroadrequirement left open. The assets are listed inreeflex-core/policy/protected.rego, and the shipped list is a floor derived from the FHS, not a description of your estate. If your production data lives somewhere else —/home/app/data, an unconventional Postgres data directory, a bucket name — R6 does not know about it until you add it there. See LEVEL 1 below: this is a one-line data edit, and it is the highest-value one in the pack — see LEVEL 1b.You can also define your own environments: the policy matches
target.environmentas a plain string, so you can gate rules on any names you use (e.g.prod-eu,critical) by editingreeflex.rego— with zero core changes.
Physical-impact rules: R8–R11 and two budgets¶
Requires a core that carries RFX-427
Added to the base pack under RFX-427, additively — no existing rule id or verdict changes. reeflex-core v0.2.3 and earlier do not have them.
All of them arm only when axes.externality is physical. The fields they
read are in the Action Envelope reference.
| id | rule id | fires when | verdict |
|---|---|---|---|
| R8 | reeflex.policy/safety_function_override_prod |
environment is production or staging, AND the verb is override, or target.safety_function is true and the verb is set, update, delete, flash or mode |
deny — no approval overrides it |
| R10a | reeflex.policy/outside_operating_envelope_prod |
environment is production or staging, AND magnitude.physical.envelope_fraction > 1.0 |
deny |
| R9 | reeflex.policy/physical_irreversible |
environment is production, reversibility is irreversible, verb is actuate or flash |
require_approval |
| R11 | reeflex.policy/mode_change_live |
environment is production, verb is mode |
require_approval |
| R10b | reeflex.policy/cumulative_budget (R5's mechanism; the reason names the dimension) |
the session's physical_writes or physical_envelope budget is exceeded |
require_approval |
Precedence, which stays total — exactly one decision for any input. Read
top to bottom; each line fires only if no line above it did (the header of
reeflex.rego):
R3 (deny) → R8 (deny) → R10a (deny) → R0 → R2 → R9 → R11 → R5 (incl. R10b) → R6 → R7 → R1 → R4
R0 sits below the three denies: when the environment R8 or R10a reads was
declared, an undeclared reversibility does not turn their deny into an R0
hold. R3 already denies irreversible + systemic + production regardless of
externality, so a systemic physical action is reported as R3, not R9. In dev
(a digital twin or simulator) none of R8–R11 arm, and neither physical budget
does. The ledger is still per session: a twin write is recorded, and counts
toward the budgets if the same session later acts on production or
staging. Give twin work its own session.
The two budgets are dimensions in budgets.rego, edited like any other
(LEVEL 1 below):
| dimension | counts | default limit |
|---|---|---|
physical_writes |
each physical action whose verb is set, mode, override, actuate or flash, charged like every count budget — magnitude.count, raised to the floor its blast_radius implies (a systemic write is charged 20 and trips at once) |
10 per session |
physical_envelope |
the sum of magnitude.physical.envelope_fraction |
1.0 per session — a full-range sweep once per session without a human |
On the defaults, many small steps trip physical_writes before they add up to
1.0: forty steps of 0.025 are held at the 11th. The worked sequence, with both
budgets, is in Industrial setpoints.
This guide does not re-derive the axis model or the shipped rules — see:
docs/why-reeflex.md— why the model looks like this, HITL/HOTL/AIL.reeflex-spec/IMPACT-MODEL.md— how impact is computed, layer by layer, and what the base policy deliberately does not catch (its closing section already names the mass-read guard used as this document's LEVEL 2 example).reeflex-spec/SPEC.md§2–§5 — the full Action Envelope and Decision contracts.
What matters for this guide is just the shape of the file you're about to
edit: a handful of <rule>_allow / <rule>_deny / <rule>_require_approval
predicates, and a handful of decision := {...} if { ... } complete-rule
blocks, each guarded by not <higher-precedence predicate> so that exactly
one block's body is true for any given envelope.
2. LEVEL 1 — change a threshold¶
The smallest possible change: one number in a data file.
Work in a copy — never edit the checked-in policy in place. So a mistake
can't dirty the shipped reeflex-core/policy/ (which must stay
byte-identical), copy the directory once and run every command below against
the copy:
The threshold you'll change lives in my-policy/budgets.rego, in the
default_budgets table:
This is R5's fragmentation-resistance budget (SPEC §4.1): the maximum
cumulative delete count allowed in a session before core requires human
approval. Say your risk tolerance is lower and you want that budget at 5,
not 20. Edit the one line:
That's the entire change, and it is data, not code. budgets.rego holds
default_budgets (applies to every principal) and principal_budgets
(per-session overrides) across four dimensions — money, deletions,
external_sends, objects_touched — each aggregating over heterogeneous
verbs. No Python and no reeflex.rego edit is involved: the rules that read
the table (reeflex.policy/session_delete_budget for deletions,
reeflex.policy/cumulative_budget for every other dimension) are untouched.
If you are following an older copy of this guide: the constant moved
Before RFX-11 generalised R5, this limit was a bare constant
delete_session_budget := 20 in reeflex.rego. That identifier is not in
reeflex.rego and not anywhere else in the shipped pack — RFX-11's PR
replaced it with the default_budgets table above. The LEVEL 2 walkthrough
further down still refers to it by the old name; that section carries its own
staleness warning (RFX-255).
But changing a threshold is a real behavior change, and your test suite is the proof of what changed — including tests you didn't intend to touch. Run the suite against your copy:
What actually happened when I ran this (measured 2026-09-23 against
reeflex-core/policy at commit ecb7753; the shipped reeflex-core/policy/
stays untouched). The pack ships 75 tests, and lowering the limit to 5
breaks two of them — in two different files:
data.reeflex.policy_test.test_r5_under_budget_allows: FAIL
data.reeflex.policy_test.test_one_call_earlier_still_allows: FAIL
--------------------------------------------------------------------------------
PASS: 73/75
FAIL: 2/75
Lowering a shared threshold means re-checking every fixture that was tuned
against the old value. That is not a bug in opa test; it is the reason you
run it before deploying. The two failures are not the same kind of thing, and
telling them apart is the whole point of this step.
Failure 1 — a stale fixture. Fix it.¶
my-policy/reeflex_test.rego's test_r5_under_budget_allows is tuned to sit
under the old budget of 20 (prior deletes = 3, this batch = 5, total = 8 —
under 20, over 5). Its old assumption ("8 is under budget") is simply no longer
true. Fix the fixture to match the new intent:
-# R5 UNDER BUDGET: prior deletes = 3, this batch = 5; total = 8 <= 20.
+# R5 UNDER BUDGET: prior deletes = 1, this batch = 2; total = 3 <= 5 (the
+# LOWERED budget).
test_r5_under_budget_allows if {
envelope := {
"action": {"verb": "delete"},
"target": {"environment": "staging"},
"axes": {"reversibility": "recoverable", "blast_radius": "scoped", "externality": "internal"},
- "magnitude": {"count": 5},
- "cumulative": {"count_by_verb": {"delete": 3}},
+ "magnitude": {"count": 2},
+ "cumulative": {"count_by_verb": {"delete": 1}},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "allow"
}
Failure 2 — not a stale fixture. It is the pack telling you what 5 costs.¶
my-policy/budgets_count_test.rego's test_one_call_earlier_still_allows
pins a broad delete of one record with 10 prior deletes as allow. It fails
at a limit of 5 and no edit to its numbers can make it pass, because of
something LEVEL 1 does not otherwise touch: budgets.rego's count_floor
charges a broad action at least 10, whatever magnitude.count says
(RFX-143). A deletions limit of 5 is below that floor, so the first broad
delete in a fresh session is already over budget. Measured, same envelope,
only the limit differing:
$ cat > env.json <<'EOF'
{"action":{"verb":"delete"},"target":{"environment":"staging","ref":null},
"axes":{"reversibility":"recoverable","blast_radius":"broad","externality":"internal"},
"magnitude":{"count":1},"cumulative":{"count_by_verb":{"delete":0},"total_count":0},
"approval":{"present":false}}
EOF
$ opa eval -d reeflex-core/policy -i env.json 'data.reeflex.policy.decision' --format raw
{"decision":"allow","reason":"no high-risk axis matched","rule":"reeflex.policy/default_allow"}
$ opa eval -d my-policy -i env.json 'data.reeflex.policy.decision' --format raw
{"decision":"require_approval","reason":"session delete budget exceeded (fragmentation guard)",
"rule":"reeflex.policy/session_delete_budget"}
So this failure is a verdict on your threshold, not on the fixture: if 5 really
is your tolerance, holding every broad delete for a human is what you are
choosing. If it is not, pick a limit above the broad floor of 10. Either way,
decide it deliberately — that is the check doing its job.
Add a test that proves the new boundary¶
A batch that trips 5 but would not have tripped 20:
# LEVEL 1 (docs/policy-guide.md): with the `deletions` limit lowered to 5, a
# first-call batch of 6 (prior_deletes defaults to 0) now trips the budget —
# it would NOT have tripped the shipped default of 20. blast_radius is
# `scoped`, whose count_floor is 1, so `magnitude.count` is the charge.
test_lowered_budget_trips_at_six if {
envelope := {
"action": {"verb": "delete"},
"target": {"environment": "staging"},
"axes": {"reversibility": "recoverable", "blast_radius": "scoped", "externality": "internal"},
"magnitude": {"count": 6},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "require_approval"
got.rule == "reeflex.policy/session_delete_budget"
}
# Sanity: a batch of 5 stays AT the new budget, not over it (5 > 5 is false)
# -> allow. Proves the boundary is ">" not ">=", matching the rule body.
test_lowered_budget_five_is_still_allowed if {
envelope := {
"action": {"verb": "delete"},
"target": {"environment": "staging"},
"axes": {"reversibility": "recoverable", "blast_radius": "scoped", "externality": "internal"},
"magnitude": {"count": 5},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "allow"
}
Re-run the same command. Raw output, measured against the copy with the Failure-1 fixture corrected and these two tests added:
$ opa test my-policy/ -v
...
data.reeflex.policy_test.test_lowered_budget_trips_at_six: PASS
data.reeflex.policy_test.test_lowered_budget_five_is_still_allowed: PASS
data.reeflex.policy_test.test_one_call_earlier_still_allows: FAIL
--------------------------------------------------------------------------------
PASS: 76/77
FAIL: 1/77
Seventy-five shipped tests plus the two new ones, one fixture corrected, and one deliberate failure left standing because it is a statement about the threshold you chose rather than about the fixture. That is the whole workflow for a threshold change: change data, run the suite, and read every failure it gives you before deciding which ones to make go away.
The numbers above are a snapshot, not a contract
75 is the shipped test count at the commit named above, and the pack grows.
reeflex-core/tests/test_policy_guide_level1_rfx11.py pins the identifiers
and file names this section tells you to edit against the shipped pack, so
the instructions cannot go stale silently again — it does not pin the counts
in the transcripts, and a count that has drifted is expected rather than a
defect.
2b. LEVEL 1b — declare your production assets (the highest-value edit)¶
reeflex-core/policy/protected.rego is the other file that is pure data, and
editing it is the single change that most changes what the pack protects.
R6 (SPEC §4.3) holds an irreversible production action on a declared
production asset at any cardinality. It exists because blast_radius is a
cardinality axis: R2 requires broad, so before R6 an irreversible
production destruction that named exactly one entity —
rm /srv/prod/db.sqlite — matched no rule and R4 allowed it.
The shipped list is a floor, and it is worth being precise about what that
means. Its entries are the locations the Filesystem Hierarchy Standard
designates for durable service state (/srv, /var/lib, /var/opt,
/var/spool, /var/backups) plus the common container mount points. That is
a defensible default and it is not a description of your estate: if your
production data lives under /home/app/data, or in a Postgres initialised
somewhere unconventional, or behind a bucket name, R6 does not know until you
say so.
protected_assets := [
"/srv/",
"/var/lib/",
# ... the shipped floor, then yours:
"/home/app/data/",
"s3://acme-prod-",
]
Prefix matching, case-insensitive, against the target.ref core has already
canonicalized (SPEC §4.3) — so .. segments, doubled separators, a trailing
newline and a zero-width character cannot spell a declared asset into an
undeclared one. Declaring "/srv/" also protects /srv itself.
The posture switch, if a floor is not enough¶
Flips the question from "is this declared production state?" to "is this
declared ephemeral?" — every irreversible production action is then held
unless its ref matches ephemeral_assets, and an action with no ref is
held too (an adapter that cannot name what it is destroying is the case a
human should see).
This is the posture that removes the floor's coverage limit, and it is off by
default for a reason worth stating plainly: it will hold deletes of paths you
have not classified yet. A control an operator switches off on day two
protects less than a smaller one they keep. Turn it on when your
ephemeral_assets list reflects your build and scratch locations.
Both lists and the switch are covered by
reeflex-core/policy/protected_test.rego and, end to end through core, by
reeflex-core/tests/test_protected_asset_rfx153.py — including one test that
copies the policy dir, flips the switch, and shows one unchanged envelope
answering allow under one posture and require_approval under the other,
with zero Python changes.
3. LEVEL 2 — add a rule end-to-end (the mass-read guard)¶
This is the crux of the guide: adding a genuinely new rule without breaking
precedence, proven with opa test rather than asserted.
This walkthrough is out of date against the shipped pack — read before you copy it (RFX-255)
The method below is still the right one: add a predicate, add one
decision block, guard every block that could also fire, prove it with
opa test. The code is written against an older reeflex.rego and no
longer applies cleanly.
- It calls its example rule R7, and the shipped pack now has a real R7
(
reeflex.policy/authority_change_prod, inauthority.rego). This is the same collision the note here used to warn about for R6 — one rule number later. - It patches blocks that no longer exist.
r5_require_approval_budgetanddelete_session_budgetwere replaced bybudget_require_approvaland the configurable dimensions inbudgets.rego. - It predates R0 and R6, so its precedence fix guards neither.
Measured: applying every step below to the shipped pack gives a green
opa checkand then fails at request time witheval_conflict_error: complete rules must not produce multiple outputs— core answering 500 on an envelope it currently gets right (a 5,000-rowreadon a declared production asset whose environment core coerced).
Until this section is rewritten, take the shape from it and take the
guard list from
how a verdict is reached:
a new require_approval block must exclude every predicate above it in
that ladder, R0 included.
The honest problem this rule solves¶
reeflex-spec/IMPACT-MODEL.md names the mass-read guard as "a natural
extension" that "is not part of the base policy." That framing is slightly
out of date in one important, verifiable way. Look at R5's actual predicate:
r5_require_approval_budget if {
prior_deletes := object.get(input, ["cumulative", "count_by_verb", "delete"], 0)
prior_deletes + input.magnitude.count > delete_session_budget
not input.approval.present
}
There is no verb guard. input.magnitude.count is added to the prior
delete count regardless of what verb the current action is. Verified
directly against the shipped policy:
$ echo '{"action":{"verb":"read"},"target":{"environment":"production"},
"axes":{"reversibility":"reversible","blast_radius":"broad","externality":"internal"},
"magnitude":{"count":5000},"approval":{"present":false}}' \
| opa eval -d reeflex-core/policy -I --format=pretty "data.reeflex.policy.decision"
{
"decision": "require_approval",
"reason": "session delete budget exceeded (fragmentation guard)",
"rule": "reeflex.policy/session_delete_budget"
}
A 5,000-record read already comes back require_approval — the base
policy is not silently allowing mass reads today. But the reason is a lie:
nothing was deleted. An operator reading the audit log sees "delete budget
exceeded" for an action that deleted nothing, which is confusing and will be
mis-triaged. The honest fix is not "add a mass-read guard where none existed"
— it's give mass reads their own rule id and their own true reason, and
make sure exactly one decision block fires so OPA doesn't choke on two
candidates being true at once.
The rule¶
Add a constant, right after delete_session_budget:
# Maximum records a single `read` action may touch before it requires human
# approval — a mass-read / exfiltration guard. Deliberately separate from
# delete_session_budget: reads and deletes are different risks and should
# not share a threshold.
mass_read_budget := 1000
Add the predicate, right after r5_require_approval_budget:
# R7 (custom): mass-read guard. R5 has NO verb guard — it adds
# input.magnitude.count for ANY verb, so a large-count `read` already trips
# r5_require_approval_budget under the misleading reason "session delete
# budget exceeded". R7 gives mass reads their own honest rule id and reason;
# precedence below makes R7 win over R5 for reads.
r7_mass_read_guard if {
input.action.verb == "read"
input.magnitude.count > mass_read_budget
not input.approval.present
}
The decision block, and the precedence fix¶
Add a new require_approval block for R7, placed between R2 and R5 in
file order (R3 deny > R2 > R7 > R5 > R1 > R4):
# require_approval (R7) — mass-read guard; fires when R3 and R2 do not, and
# takes precedence over R5 for reads (see r7_mass_read_guard comment above).
decision := {
"decision": "require_approval",
"reason": "mass read exceeds session read budget (exfiltration guard)",
"rule": "reeflex.policy/mass_read_guard",
} if {
r7_mass_read_guard
not r3_deny
not r2_require_approval
}
Now the part that is easy to get wrong. R5's existing block did not know
about R7, so a read with count = 5000 satisfied both the new R7 block
above and the existing R5 block below — two candidate values for the same
complete rule. I proved this is a real failure, not a hypothetical, by
running the unguarded version through opa test:
data.reeflex.policy_test.test_r7_mass_read_gets_its_own_rule_not_session_delete_budget: ERROR (1ms)
reeflex.rego:113: eval_conflict_error: complete rules must not produce multiple outputs
data.reeflex.policy_test.test_r7_boundary_one_over_triggers_mass_read_guard: ERROR (0s)
reeflex.rego:113: eval_conflict_error: complete rules must not produce multiple outputs
--------------------------------------------------------------------------------
PASS: 14/16
ERROR: 2/16
eval_conflict_error is OPA refusing to pick a winner between two decision
values that are simultaneously true — exactly the "second decision value ...
CONFLICT" failure mode this task called out. (Operationally this is not a
silent widen: reeflex-core's app/opa.py treats any non-zero OPA exit or
malformed result as OpaEvalError and the caller denies — fail-closed holds
even here. But a hard deny on every mass read, with no clear reason, is not
the outcome you want either.) The fix is the one-line precedence guard the
brief specified — add not r7_mass_read_guard to the R5 block:
decision := {
"decision": "require_approval",
"reason": "session delete budget exceeded (fragmentation guard)",
"rule": "reeflex.policy/session_delete_budget",
} if {
r5_require_approval_budget
not r3_deny
not r2_require_approval
+ not r7_mass_read_guard
}
And, for full correctness regardless of how the two constants are tuned
relative to each other, the same guard on both allow blocks (R1 and R4).
Without it, a deployment that (mis)configures mass_read_budget below
delete_session_budget could hit a read that trips R7 but not R5, which
would otherwise fall through to an allow block that has no idea R7 exists —
the same kind of two-candidate conflict, just reachable from a different
angle:
decision := {
"decision": "allow",
"reason": "read-only internal action",
"rule": "reeflex.policy/read_only_internal",
} if {
r1_allow
not r2_require_approval
not r3_deny
not r5_require_approval_budget
+ not r7_mass_read_guard
}
decision := {
"decision": "allow",
"reason": "no high-risk axis matched",
"rule": "reeflex.policy/default_allow",
} if {
not r1_allow
not r2_require_approval
not r3_deny
not r5_require_approval_budget
+ not r7_mass_read_guard
}
Total precedence is now R3 (deny) > R2 > R7 > R5 > R1 > R4, still total —
every predicate combination lands in exactly one block.
Tests — precedence proven, not asserted¶
# THE CRUX: a mass read (count=5000, internal, no prior deletes) used to
# surface as "session delete budget exceeded" — an honest decision, a
# misleading reason. R7 now gives it its own rule id.
test_r7_mass_read_gets_its_own_rule_not_session_delete_budget if {
envelope := {
"action": {"verb": "read"},
"target": {"environment": "production"},
"axes": {"reversibility": "reversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 5000},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "require_approval"
got.rule == "reeflex.policy/mass_read_guard"
}
# BOUNDARY at mass_read_budget itself (1000): not strictly greater, so R7
# does NOT fire — but 1000 is still > delete_session_budget (20), so R5's
# verb-agnostic count check still fires. Honest limit of the fix: reads
# between the two thresholds still carry the older, less precise reason.
test_r7_boundary_at_budget_falls_back_to_r5 if {
envelope := {
"action": {"verb": "read"},
"target": {"environment": "production"},
"axes": {"reversibility": "reversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 1000},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "require_approval"
got.rule == "reeflex.policy/session_delete_budget"
}
# One count over the boundary (1001) DOES fire R7.
test_r7_boundary_one_over_triggers_mass_read_guard if {
envelope := {
"action": {"verb": "read"},
"target": {"environment": "production"},
"axes": {"reversibility": "reversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 1001},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "require_approval"
got.rule == "reeflex.policy/mass_read_guard"
}
# Approval clears BOTH r5 and r6 -> falls through to R1 (read-only) -> allow.
test_r7_mass_read_with_approval_allows if {
envelope := {
"action": {"verb": "read"},
"target": {"environment": "production"},
"axes": {"reversibility": "reversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 5000},
"approval": {"present": true},
}
got := policy.decision with input as envelope
got.decision == "allow"
got.rule == "reeflex.policy/read_only_internal"
}
# PRECEDENCE: R2 still outranks R7 for a read that also matches R2's axes.
test_r2_outranks_r7_for_qualifying_read if {
envelope := {
"action": {"verb": "read"},
"target": {"environment": "production"},
"axes": {"reversibility": "irreversible", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 5000},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "require_approval"
got.rule == "reeflex.policy/irreversible_broad_prod"
}
# PRECEDENCE: R3 (deny) still outranks R7.
test_r3_outranks_r7_for_qualifying_read if {
envelope := {
"action": {"verb": "read"},
"target": {"environment": "production"},
"axes": {"reversibility": "irreversible", "blast_radius": "systemic", "externality": "internal"},
"magnitude": {"count": 5000},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "deny"
got.rule == "reeflex.policy/irreversible_systemic_prod"
}
# NON-READ verbs are unaffected: R7 checks verb == "read" explicitly.
test_r7_does_not_fire_for_non_read_verbs if {
envelope := {
"action": {"verb": "delete"},
"target": {"environment": "staging"},
"axes": {"reversibility": "recoverable", "blast_radius": "broad", "externality": "internal"},
"magnitude": {"count": 5000},
"approval": {"present": false},
}
got := policy.decision with input as envelope
got.decision == "require_approval"
got.rule == "reeflex.policy/session_delete_budget"
}
Raw opa test -v output, verified in a scratch copy, all 9 original tests
plus 7 new R7 tests, no conflicts:
data.reeflex.policy_test.test_r3_irreversible_systemic_prod_deny: PASS (1.1707ms)
data.reeflex.policy_test.test_r5_budget_exceeded_but_approved_allows: PASS (526.7µs)
data.reeflex.policy_test.test_r5_under_budget_allows: PASS (526.7µs)
data.reeflex.policy_test.test_r5_budget_exceeded_triggers_require_approval: PASS (1.6895ms)
data.reeflex.policy_test.test_r7_does_not_fire_for_non_read_verbs: PASS (1.6895ms)
data.reeflex.policy_test.test_r2_irreversible_broad_prod_require_approval: PASS (1.6451ms)
data.reeflex.policy_test.test_r2_outranks_r7_for_qualifying_read: PASS (2.1718ms)
data.reeflex.policy_test.test_r1_read_internal_allow: PASS (2.1501ms)
data.reeflex.policy_test.test_r5_absent_cumulative_does_not_crash: PASS (1.6313ms)
data.reeflex.policy_test.test_r7_mass_read_with_approval_allows: PASS (2.6768ms)
data.reeflex.policy_test.test_r7_boundary_at_budget_falls_back_to_r5: PASS (2.814ms)
data.reeflex.policy_test.test_r4_default_allow: PASS (1.1729ms)
data.reeflex.policy_test.test_r7_boundary_one_over_triggers_mass_read_guard: PASS (1.7685ms)
data.reeflex.policy_test.test_precedence_deny_over_require_approval: PASS (1.1462ms)
data.reeflex.policy_test.test_r7_mass_read_gets_its_own_rule_not_session_delete_budget: PASS (1.1462ms)
data.reeflex.policy_test.test_r3_outranks_r7_for_qualifying_read: PASS (711.2µs)
--------------------------------------------------------------------------------
PASS: 16/16
What this example does NOT claim: mass reads between delete_session_budget
(20) and mass_read_budget (1000) still surface under the older
session_delete_budget rule id — test_r7_boundary_at_budget_falls_back_to_r5
proves that boundary rather than hiding it. Pick your own mass_read_budget
deliberately; this guide's 1000 is illustrative, not a recommendation.
4. LEVEL 3 — replace the whole policy¶
You are not restricted to editing the shipped file in place. reeflex-core
loads the policy directory from an environment variable, read in
reeflex-core/app/opa.py:
def _policy_dir() -> str:
env_dir = os.environ.get("REEFLEX_POLICY_DIR", "")
if env_dir:
return env_dir
# Default: <repo root>/reeflex-core/policy (two levels up from this file)
here = pathlib.Path(__file__).resolve()
return str(here.parent.parent / "policy")
Every /v1/decide call runs opa eval -d <policy_dir> -I --format=json
data.reeflex.policy.decision against that directory (same file, same
function). There is exactly one knob: REEFLEX_POLICY_DIR. Point it at
your own directory containing your own .rego files and core will evaluate
those instead — no core code change, no rebuild required if you're bind-
mounting.
Two ways to use it:
Running from source (see reeflex-core/README.md env var table):
export REEFLEX_OPA_BIN=opa
export REEFLEX_POLICY_DIR=/path/to/your-policy
python reeflex-core/main.py
Running the container — the Dockerfile bakes the
default in (ENV REEFLEX_POLICY_DIR=/app/policy); override it and bind-mount
your directory over it in docker-compose.yml:
services:
core:
# ...existing config (image/build, ports, healthcheck)...
environment:
REEFLEX_POLICY_DIR: /policy # overrides the Dockerfile default (/app/policy)
volumes:
- ./my-policy:/policy:ro
Whatever package you write MUST still expose data.reeflex.policy.decision
as the query root — that is the one contract app/opa.py depends on
(query = "data.reeflex.policy.decision"). Everything else — how many rules,
what they're called, how you organize files inside the directory — is yours.
The input contract¶
Your policy reads fields off input, the Action Envelope. The fields the
shipped rules actually touch (there is no reason your own policy is
limited to these — this is the minimum a Reeflex-aware policy typically
needs):
| Field | Type | Used by |
|---|---|---|
input.action.verb |
string (read/create/update/delete/execute/transact/emit) |
R1, R7 |
input.axes.reversibility |
reversible/recoverable/irreversible |
R2, R3, R6 |
input.axes.blast_radius |
single/scoped/broad/systemic |
R2, R3 |
input.axes.externality |
internal/outbound/physical |
R1 |
input.target.environment |
production/staging/dev |
R2, R3, R6 |
input.target.ref |
string or null; canonicalized by core (SPEC §4.3) |
R6 |
input.magnitude.count |
integer | R5, R7 |
input.axes.externality |
internal/outbound/physical |
R1 (internal), R5's external_sends budget (outbound), R8–R11 and the physical_writes / physical_envelope budgets (physical, SPEC §4.5) |
input.target.environment |
production/staging/dev |
R2, R3 |
input.magnitude.count |
integer >= 1; absent -> 1, and the fill is recorded in input.provenance.undeclared (RFX-143) |
R5, R6 |
input.cumulative.count_by_verb.* |
object, injected by core before eval (SPEC §4.1) | R5 (.delete) |
input.approval.present |
boolean | R5, R7 |
Full envelope shape, including agent, target.kind, params, context,
and meta: reeflex-spec/SPEC.md §2. The
cumulative object and why fragmentation resistance needs it: SPEC §4.1.
Any field your policy reads and finds missing should resolve conservatively
— never toward allow (the shipped R5 predicate does this explicitly with
object.get(..., 0) for cumulative, defaulting absent prior activity to
zero rather than erroring or unblocking).
The output contract¶
Your policy's decision rule must produce an object shaped like this
(SPEC §5):
{
"decision": "require_approval", // allow | deny | require_approval — required
"reason": "...", // human-readable, for audit — required
"rule": "your.policy/rule_id", // fired rule id, for audit — required
"obligations": ["audit:full"] // optional; passed through verbatim by app/opa.py if present
}
app/opa.py reads exactly these keys off the returned value
(value.get("decision"), value.get("reason", ""), value.get("rule", ""),
value.get("obligations", [])) — add an "obligations" key to any decision
block and core forwards it to the adapter without any code change on the
core side.
5. Testing + safety¶
Always run opa test before deploying a policy change — against
whichever directory you're about to point REEFLEX_POLICY_DIR at:
or, for a custom policy directory:
Two structural guarantees hold regardless of what you write:
- Fail-closed is not a policy responsibility — it's the engine's.
app/opa.py'sevaluate()raisesOpaEvalErroron the OPA binary being missing, a non-zero exit, a timeout, malformed JSON, or an undefined/empty result (including theeval_conflict_errorcase shown in LEVEL 2 above, before it was fixed) — the caller converts that into adeny, never anallow. A broken or ambiguous policy denies traffic; it does not open it. - No LLM, no network, no wall-clock time in the decision path. The
policy reads only
inputand its own constants. Do not call out to an external data source, do not branch ontime.now_ns(), do not read free-text/markdown/OKF content as a decision input — any of those would break "same envelope in, same decision out," which is the actual determinism guarantee this product sells (seedocs/adr/0002-no-llm-in-decision-path.md).
Every example in this guide was verified with these exact commands, in a
scratch copy of reeflex-core/policy/, never against the checked-in files —
see the raw opa test output inline in sections 2 and 3 above.