Skip to content

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. In dev, 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. Set target.environment accordingly; 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.sqlite hole R2's broad requirement left open. The assets are listed in reeflex-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.environment as a plain string, so you can gate rules on any names you use (e.g. prod-eu, critical) by editing reeflex.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:

cp -r reeflex-core/policy my-policy

The threshold you'll change lives in my-policy/budgets.rego, in the default_budgets table:

"deletions": {"limit": 20},

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:

"deletions": {"limit": 5},

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:

opa test my-policy/ -v

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

default_protected := true

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, in authority.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_budget and delete_session_budget were replaced by budget_require_approval and the configurable dimensions in budgets.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 check and then fails at request time with eval_conflict_error: complete rules must not produce multiple outputs — core answering 500 on an envelope it currently gets right (a 5,000-row read on 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:

opa test reeflex-core/policy/ -v

or, for a custom policy directory:

opa test /path/to/your-policy -v

Two structural guarantees hold regardless of what you write:

  • Fail-closed is not a policy responsibility — it's the engine's. app/opa.py's evaluate() raises OpaEvalError on the OPA binary being missing, a non-zero exit, a timeout, malformed JSON, or an undefined/empty result (including the eval_conflict_error case shown in LEVEL 2 above, before it was fixed) — the caller converts that into a deny, never an allow. 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 input and its own constants. Do not call out to an external data source, do not branch on time.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 (see docs/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.