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 six rules (R1–R6) 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.

Environment matters. R2, R3 and R6 are gated on production. In dev, staging, or any other environment, an irreversible / broad / systemic action is not held or denied by R2/R3/R6 — only R1 (read allow), R5 (cumulative budgets), and R4 (default allow) apply. Set target.environment accordingly; the stricter behavior is intentional for production only.

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.

This guide does not re-derive the axis model or the five 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 constant.

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 constant you'll change lives in my-policy/reeflex.rego:

delete_session_budget := 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:

delete_session_budget := 5

That's the entire code change. But changing a constant 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 against the copy (the shipped reeflex-core/policy/ stays untouched): the shipped test test_r5_under_budget_allows uses a fixture tuned to sit under the old budget of 20 (prior deletes = 3, this batch = 5, total = 8 — under 20, over 5). At this point you have only the nine shipped tests — the two boundary tests below aren't added yet — so the count is out of nine:

FAILURES
--------------------------------------------------------------------------------
data.reeflex.policy_test.test_r5_under_budget_allows: FAIL (1.0406ms)
  ...
  my-policy/reeflex_test.rego:108   | | Fail got.decision = "allow"
--------------------------------------------------------------------------------
PASS: 8/9
FAIL: 1/9

That failure is opa test doing its job: total = 8 is now over the new budget of 5, so the fixture's old assumption ("8 is under budget") is no longer true, and the test correctly says so. Lowering a shared constant means re-checking every fixture that was tuned against the old value — this is not a bug in opa test, it is the reason you run it before deploying. Fix the fixture to match the new intent ("a small batch under the new budget still allows"):

-# 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"
 }

Then 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 delete_session_budget 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.
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, verified against the copy:

$ opa test my-policy/ -v
...
data.reeflex.policy_test.test_r1_read_internal_allow: PASS (2.1634ms)
data.reeflex.policy_test.test_r2_irreversible_broad_prod_require_approval: PASS (2.1482ms)
data.reeflex.policy_test.test_r3_irreversible_systemic_prod_deny: PASS (1.5904ms)
data.reeflex.policy_test.test_r4_default_allow: PASS (2.0952ms)
data.reeflex.policy_test.test_precedence_deny_over_require_approval: PASS (1.6273ms)
data.reeflex.policy_test.test_r5_budget_exceeded_triggers_require_approval: PASS (2.1634ms)
data.reeflex.policy_test.test_r5_under_budget_allows: PASS (1.0939ms)
data.reeflex.policy_test.test_r5_budget_exceeded_but_approved_allows: PASS (2.0952ms)
data.reeflex.policy_test.test_r5_absent_cumulative_does_not_crash: PASS (1.0939ms)
data.reeflex.policy_test.test_lowered_budget_trips_at_six: PASS (1.109ms)
data.reeflex.policy_test.test_lowered_budget_five_is_still_allowed: PASS (1.5904ms)
--------------------------------------------------------------------------------
PASS: 11/11

Nine original tests plus two new ones, all green, one fixture corrected. That is the whole workflow for a threshold change.


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.

The example rule below is numbered R7, not R6. It used to be R6, and it was renumbered when the base pack gained a real R6 (the declared-production- asset rule, SPEC §4.3). Leaving it as R6 would have had you add a second rule under a number the shipped pack already uses — a name collision with a genuine precedence bug behind it, which is exactly the class of mistake this section is about avoiding. The rule id string (reeflex.policy/mass_read_guard) never depended on the number.

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). physical: no rule — RFX-129
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.