Architecture diagrams¶
The single-path system overview (agent -> adapter -> core -> decision) is on
the Concepts page. This page goes one level deeper: the
/v1/decide round-trip, how the engine actually reaches a verdict — every
rule, by its real rule id, what happens to the record afterwards and which
of its anchors you can actually check, the hold lifecycle, where things run, and the
honest trade-off between the three places an adapter can sit. For the prose
architecture (seams, guarantees, traceability), see the
architecture reference.
The decision round-trip¶
sequenceDiagram
autonumber
participant Ag as AI agent
participant Ad as Adapter
participant Co as reeflex-core
participant Op as OPA/Rego
Ag->>Ad: attempt a backend action
Ad->>Ad: normalize to Action Envelope
Ad->>Co: POST /v1/decide (envelope)
Co->>Co: inject cumulative session ledger
Co->>Op: evaluate policy (pure, no LLM)
Op-->>Co: allow / deny / require_approval
Co->>Co: append audit record
Co-->>Ad: Decision (+ decision_id)
Ad->>Ad: enforce - run, block, or hold
Ad-->>Ag: result, or a reason it can read
The /v1/decide round-trip. The adapter never touches the backend until the
verdict is in; reeflex-core decides deterministically over the per-session
ledger and records an audit entry either way. On require_approval the adapter
stores a hold instead of executing (see the hold lifecycle below). Same envelope
in, same decision out. Step 5 — "evaluate policy" — is the one box this page
used to leave closed; the next diagram opens it.
How a verdict is reached¶
flowchart TD
EV["Action Envelope
POST /v1/decide
verb · target · magnitude
axes: reversibility,
blast_radius, externality"]
CAN{"every closed-enum
value recognised?"}
COE["FAIL CLOSED · SPEC 4.0
coerce to the
most-guarded member,
record it in
provenance.undeclared"]
OK["nothing guessed
provenance.undeclared
is empty"]
LED[("per-session ledger
cumulative state,
injected before
any rule runs")]
EV --> CAN
CAN -- "no: absent,
misspelled, or
outside the enum" --> COE
CAN -- "yes" --> OK
COE --> LED
OK --> LED
LED --> R3
R3{"R3
irreversible
+ systemic, all
three DECLARED
PRODUCTION ONLY"}
R8{"R8
physical: override, or
a write to a declared
safety function
PRODUCTION + STAGING"}
R10a{"R10a
physical: request
outside the declared
safe range
PRODUCTION + STAGING"}
R0{"R0
does this verdict rest
on a value core
GUESSED?
reads R2 / R3 / R6 /
R8 / R9 / R10a / R11"}
R2{"R2
irreversible
+ broad
PRODUCTION ONLY"}
R9{"R9
physical irreversible
actuate / flash
PRODUCTION ONLY"}
R11{"R11
physical mode change
PRODUCTION ONLY"}
R5{"R5 (incl. R10b)
any cumulative
budget exceeded?
any environment"}
R6{"R6
irreversible on a
DECLARED prod asset
any cardinality
PRODUCTION ONLY"}
R7{"R7
changes authority,
credentials, or
what code runs
PRODUCTION ONLY"}
R1{"R1
read + internal?
any environment"}
LED -. "the one rule
that reads it" .-> R5
R3 -- no --> R8
R8 -- no --> R10a
R10a -- no --> R0
R0 -- no --> R2
R2 -- no --> R9
R9 -- no --> R11
R11 -- no --> R5
R5 -- no --> R6
R6 -- no --> R7
R7 -- no --> R1
R1 -- no --> A4
H0["HOLD
reeflex.policy/unclassified_action"]
D3["DENY · TERMINAL
no approval clears it
reeflex.policy/irreversible_systemic_prod"]
D8["DENY · TERMINAL
no approval clears it
reeflex.policy/safety_function_override_prod"]
D10["DENY · TERMINAL
no approval clears it
reeflex.policy/outside_operating_envelope_prod"]
H2["HOLD
reeflex.policy/irreversible_broad_prod"]
H9["HOLD
reeflex.policy/physical_irreversible"]
H11["HOLD
reeflex.policy/mode_change_live"]
H5["HOLD
reeflex.policy/session_delete_budget
or reeflex.policy/cumulative_budget"]
H6["HOLD
reeflex.policy/irreversible_protected_asset_prod"]
H7["HOLD
reeflex.policy/authority_change_prod"]
A1["ALLOW
reeflex.policy/read_only_internal"]
A4["ALLOW · the default
reeflex.policy/default_allow"]
R3 -- yes --> D3
R8 -- yes --> D8
R10a -- yes --> D10
R0 -- yes --> H0
R2 -- yes --> H2
R9 -- yes --> H9
R11 -- yes --> H11
R5 -- yes --> H5
R6 -- yes --> H6
R7 -- yes --> H7
R1 -- yes --> A1
%% Invisible links, same idiom as the three-seams diagram below. Without
%% them dagre centres each test over BOTH of its children, so the ladder
%% staircases sideways one half-node per rung and the whole graph ends up
%% ~1900px wide — which the 740px reading column then shrinks to 6px type.
%% Chaining the verdicts pins them into their own column: two columns, no
%% drift. The ranks are already consecutive, so this constrains nothing
%% the layout did not already imply.
D3 ~~~ D8 ~~~ D10 ~~~ H0 ~~~ H2 ~~~ H9 ~~~ H11 ~~~ H5 ~~~ H6 ~~~ H7 ~~~ A1
classDef deny fill:#7f1d1d,stroke:#ef4444,color:#fff;
classDef hold fill:#78350f,stroke:#f59e0b,color:#fff;
classDef allow fill:#14532d,stroke:#22c55e,color:#fff;
classDef coerce fill:#1e3a8a,stroke:#60a5fa,color:#fff;
class D3,D8,D10 deny;
class H0,H2,H9,H11,H5,H6,H7 hold;
class A1,A4 allow;
class COE coerce;
How an Action Envelope becomes one verdict. Read it top to bottom: the first question whose answer is "yes" decides, and nothing below it is consulted.
The envelope is cleaned up before any rule sees it. Core accepts only
known values for its closed lists. Anything it does not recognise — a missing
axis, a typo, an environment called qa-eu — is replaced with the most
guarded value in that list, so an adapter can never buy a softer verdict by
saying less. Core also writes down which fields it had to fill in.
R0 exists because of what that clean-up used to cause. Fill in three
blanks conservatively and they compose into "irreversible + systemic +
production", which is R3 — a refusal a human is not allowed to overturn.
So a product whose promise is "when unsure, ask" was answering "when unsure,
refuse". R0 catches exactly that case and turns it into a hold, not a deny:
unclassified_action says "I could not tell what this was — a human decides",
names the fields core guessed, and is resolvable like any other hold. Measured:
two envelopes that are byte-identical after clean-up, differing only in whether
the adapter declared the values, answer deny / irreversible_systemic_prod and
require_approval / unclassified_action respectively.
Environment decides which rules are even in play. R2, R3, R6 and R7 all
require target.environment == "production". For an envelope that is not
declared physical, in dev or staging only R1, R5 and R4 can fire —
measured over all 864 combinations of the three axes, three verbs, two refs and
two abilities: outside production the only rule ids that appear are
read_only_internal and default_allow. That measurement predates the
physical rules. R9 and R11 are production-only as well; R8 and R10a also arm in
staging, because for a plant staging means a HIL rig or shadow controller
with real actuators. None of R8–R11 arms in dev (the digital twin) — and that includes the two
physical budgets (R10b). Every other R5 budget is deliberately not
environment-gated; a budget is about accumulation, not about where.
Three rules are terminal denies: R3, R8 and R10a. Every other refusal
on this page is a hold, and a hold is resolvable by an approver you designate.
Core enforces that asymmetry in code, not just in policy:
NON_RESOLVABLE_RULES contains irreversible_systemic_prod,
safety_function_override_prod and outside_operating_envelope_prod, so an
attempt to approve one of them is refused with rule_not_resolvable.
Total precedence is deny > require_approval > allow — with one
deliberate exception, R0, which outranks R3's deny whenever R3 matched on a
value core guessed. (That is why the ladder asks "R3, all three declared"
first.) The reasoning is worth stating plainly: a deny that rests on a value
core invented is not a control doing its job, it is a coverage gap that happens
to look strict, and the two should not be reported under the same rule id. R0
does not outrank R8 or R10a when the environment they read was declared:
neither reads reversibility or blast radius, so leaving one of those out must
not turn a safety-function deny into a hold. Among the holds the order is
R2 → R9 → R11 → R5 → R6 → R7, and it is a reporting decision rather than a
safety one: each of those returns require_approval either way, so the
ordering only picks which reason the operator reads. The ladder above is the
order the shipped file evaluates in; reeflex-core/tests/test_decision_totality.py
checks over 91,800 envelopes that exactly one of these ids answers each.
The rule bodies are in
reeflex-core/policy/reeflex.rego,
with R5's budgets (including the two physical ones), R6's asset list and R7's signal lists in the sibling
budgets.rego, protected.rego and authority.rego — all four are files you
are meant to edit. See the policy guide to change a
threshold or add a rule of your own.
The evidence path: what happens to the record after the verdict¶
The diagram above ends where most descriptions of a gate end — at the verdict. This one starts there. A decision is only worth as much as the record it leaves, and that record travels three different distances, under three different owners, with three different things it is allowed to claim.
flowchart TB
DEC["POST /v1/decide
one verdict,
one decision_id"]
subgraph OPEN["Open tier — Apache 2.0"]
direction TB
AUD[("audit JSONL
append-only,
read back
NOT signed")]
GW["gateway seat
sees a proposal"]
GWL[("the seat's
own ledger
enforcement_stage:
refused_at_gateway")]
SIE["syslog to
your SIEM
DISABLED by default
nothing downstream
reads it"]
end
subgraph COMM["Commercial tier — closed"]
direction TB
CONN["evidence connector
tails the audit log"]
WIRE["POST /api/v1/evidence
ONE HMAC-SHA256
per DELIVERY"]
STORE[("evidence store
INSERT + SELECT only
stamps received_ts")]
REP["Attest report
four renderers,
one object"]
end
DEC -- "best
effort" --> AUD
DEC --> GW
GW --> GWL
GW -- "agent_id
only" --> WIRE
AUD --> CONN
CONN -- "closed
allowlist" --> WIRE
WIRE -- "checked
once" --> STORE
STORE --> REP
DEC -- "off by
default" --> SIE
GWL -. "no wire field
stops here" .-> WIRE
%% Invisible links, same idiom as the three-seams diagram below. Two
%% measured problems they fix, both in the rendered image and neither
%% visible in the source:
%% --
%% 1. The tiers interleave ranks (the connector sits at the same depth as
%% the seat's ledger), and dagre answers that by placing the clusters
%% SIDE BY SIDE — 2211px wide, 5px labels. Pushing the connector below
%% the seat's ledger separates the rank bands so the tiers STACK: open
%% above closed, the order the prose reads them in. (A
%% subgraph-to-subgraph `OPEN ~~~ COMM` was tried first and measured a
%% NO-OP — the constraint has to land on nodes.)
%% 2. With all three of core's traces on one rank, `GW --> GWL` routed
%% BEHIND the SIEM node, so the picture read "gateway seat -> syslog to
%% your SIEM" — an edge this graph does not declare. Giving the open
%% tier its own spine removes the crossing.
AUD ~~~ GW
GWL ~~~ CONN
classDef open fill:#14532d,stroke:#22c55e,color:#fff;
classDef comm fill:#4c1d95,stroke:#a78bfa,color:#fff;
classDef gate fill:#1e3a8a,stroke:#60a5fa,color:#fff;
class AUD,SIE,GW,GWL open;
class CONN,WIRE,STORE,REP comm;
class DEC gate;
One decision, three traces. decision_id is what joins them — core writes
it on the audit record, includes it in every SIEM event, and it is a required
field of the evidence wire, so a row in an Attest report, a line in your syslog
and a line in the JSONL can be tied to the same transit.
Where the open-core boundary actually falls. The engine, every adapter, the audit log and the SIEM emitter are Apache 2.0 and in the public repo. The connector, the ingest API, the evidence store and the report are the commercial tier and are in no public repository — see the open-core boundary. Note what that means in practice: the full decision record is yours from the open tier alone. The paid tier does not add safety or a better record; it adds the packaging an auditor asks for.
The evidence delivery is downstream of the audit log, not parallel to it. The connector does not observe decisions — it tails the same append-only JSONL core already wrote, from a persisted byte-offset cursor, so a restart resumes without re-reading or skipping. The consequence is worth stating plainly: if the audit write did not happen, nothing downstream of it happened either. And that write is best-effort by design — every audit call in the decision path goes through a wrapper that swallows its own failure, because the alternative is a full disk refusing decisions. So "every verdict has a record" is an assumption about your disk, not a property of the gate.
Four fields core records never reach a report. The mapper is a closed
allowlist built from the frozen wire schema, and it drops action.ability,
reason, session_id and cumulative_injected rather than inventing a home
for them. action.ability is the one that tends to surprise: it is the
fine-grained name of what was attempted, it is in your audit log, and it is not
in the report. Unknown top-level keys are rejected by the server with a 422, so
this cannot be worked around by a well-meaning adapter — it needs a change to a
frozen contract with deployed gates.
The SIEM stream never blocks the gate. It is disabled by default, and
when enabled it is fire-and-forget: a full queue or an unreachable collector is
swallowed rather than raised into /v1/decide. It is a monitoring path, not an
evidence path — nothing downstream of Attest reads it. The event schema, the CEF
mapping and the three environment variables are in the
SIEM integration reference, which is versioned against the engine
it documents.
What the gateway seat may claim, and what it may not. This is the one
field on this page an evidence pipeline can read as stronger than it is. A
gateway sees a tool call proposed; it removes the instruction from the
response, and it does not see the tool execute. So the seat writes
enforcement_stage: refused_at_gateway, alongside observed: proposal and
prevents_execution: false. prevented_at_execution exists in the same shared
vocabulary — the execution-side seats are what earn it — and this seat can never
emit it: handing that value to the ledger writer raises, by a runtime guard
rather than a comment. A pipeline that counts both stages as "N actions
prevented" is counting some that were only ever un-suggested.
And the honest twist: that field never reaches a report at all. There are
two destinations with opposite rules. enforcement_stage lives in full in the
seat's own local ledger — so a log shipper or a SIEM pointed at that file can
over-read it. It cannot go on the evidence wire, because the wire's schema is
closed and has no field for it. Inside the frozen contract the only thing that
records which seat decided is agent_id, which this adapter sets to
agent:litellm-gateway/<org>/<model>. That is a weaker signal on purpose: it
says where the decision was made, not what the decision achieved.
Which clock, and whose signature. Two of the boxes above carry words
that are easy to round up, so this page says which reading is the honest one for
what it draws. "Stamps received_ts" means the store's own clock, which no
gate credential can set — so a surface built on this path may say Reeflex knows
when a record arrived, and may not say it knows when the decision was taken;
the time the decision was taken is occurred_ts, which is whatever the gate
declared. "One HMAC per delivery" is the whole of the signing: it
authenticates who sent a batch, it is stored on every row of that batch, and
because its signed input is not persisted nobody can re-verify it after
ingest, Reeflex included — so "each record is signed" is never true of this
path. "Append-only" is the one that is simply true, and it is enforced below
the application, by a runtime role that holds INSERT and SELECT and neither
UPDATE nor DELETE. Per-record signing that a third party could check without
trusting anyone is the Ed25519 upgrade, on the roadmap and not shipped;
sig_alg is versioned so records stay readable under the scheme they were
written with.
envelope_hash is the anchor most often misread, and it is worth one sentence
because the diagram carries it end to end. Core computes it over four blocks of
the envelope — action, axes, magnitude, target — so it fingerprints the
action, not the record: two decisions differing only in who acted or when
carry the same value. The app never receives the envelope, so its pre-image is
stored nowhere and no party, Reeflex included, can recompute it. It is
carried, and checked for shape. That is the whole of it.
This page stops at the store, because that is where the open tier's architecture stops — and the question it leaves open is not architectural but evidential: once the record has travelled all of that, what is it actually worth to an auditor? Evidence for auditors answers that, and answers it against the same three anchors this diagram labels: it takes append-only, signed and timestamped one word at a time — one survives, one is half true, one is true of something other than what you would assume — and it lists the six sections a report comes out in, together with which frameworks the controls are mapped to rather than certified against. The per-article and per-field detail stays with the commercial tier rather than here.
Hold lifecycle¶
stateDiagram-v2
[*] --> pending: require_approval creates a hold
pending --> approved: an approver you trust resolves it
pending --> rejected: approver rejects
pending --> expired: TTL elapses
approved --> consumed: adapter re-submits, envelope-hash matches
rejected --> [*]
expired --> [*]
consumed --> [*]
A hold is single-use and time-bound. Core enforces actor != approver (the
agent that raised the hold can never resolve it), the TTL (expires_ts), and
envelope-hash binding (the approved action is the exact one submitted). Resolve
holds from wp-admin, the reeflex-holds MCP server,
or the resolution API. See
HIL / HOTL / AIL for who may resolve what.
Deployment: self-hosted, adapters call core¶
flowchart TB
subgraph net["Your network / infrastructure"]
core["reeflex-core (stateless container)"]
subgraph src["Source-side adapters"]
cc["Claude Code hook"]
n8["n8n gate node"]
end
subgraph res["Resource-side adapters"]
wp["WordPress gate"]
gw["MCP gateway proxy"]
end
end
cc -- "POST /v1/decide" --> core
n8 -- "POST /v1/decide" --> core
wp -- "POST /v1/decide" --> core
gw -- "POST /v1/decide" --> core
The only production-supported topology is on-prem: everything runs inside your
own network and no decision data leaves it. reeflex-core is a stateless
container; every adapter reaches it over one HTTP call. (An opt-in public eval
endpoint exists for trying it - see Getting started.)
Adapter placement: three seams¶
flowchart TB
subgraph SRC["Source-side: adapter in the agent"]
direction LR
AG["one agent"] --> AD1["adapter
Claude Code, n8n"]
AD1 --> B1["Postgres"]
AD1 --> B2["S3"]
AD1 --> B3["files"]
end
subgraph GW["Gateway-side: adapter in an LLM gateway - OPTIONAL"]
direction LR
G1["any agent"] --> AD3["adapter in the gateway
e.g. LiteLLM"]
G2["any model"] --> AD3
AD3 --> P1["tool call PROPOSED,
not executed
stage: refused_at_gateway"]
end
subgraph RES["Resource-side: adapter in the backend"]
direction LR
C1["any caller"] --> AD2["adapter
WordPress, MCP proxy"]
C2["another agent"] --> AD2
AD2 --> DB["one backend"]
end
%% Invisible links: keep the three seams in reading order.
SRC ~~~ GW ~~~ RES
The honest trade-off, in three places you can put a seam. Each governs a different set, and none of them is a prerequisite for the others.
Source-side — the adapter lives in the agent (Claude Code hook, n8n node). It governs one agent wherever it acts, across every backend it touches. Another agent hitting the same backend is ungoverned.
Gateway-side — the adapter lives in an LLM gateway. It governs every agent behind that gateway, whatever model they use, and it does so at proposal time. This seam is optional: it exists for teams that already run a gateway, and Reeflex neither ships one nor requires one. LiteLLM is the instance we ship an adapter for, and it is an example of the seam, not a dependency of the product — if you do not run a gateway, the other two seams work exactly as they do above.
The gateway seam's honest limit, because it changes what it is worth. A
gateway sees a tool call proposed by the model, not executed. Refusing
there stops the proposal from ever reaching the agent — the refusal is recorded
as stage: refused_at_gateway — but nothing at that seam observes the action
actually running, and an agent that acts without going back through the gateway
is outside it entirely. It is not a substitute for an execution-side seat.
Resource-side — the adapter lives in the backend (WordPress, MCP gateway proxy). It governs every caller of one backend, including callers you did not know about, but only that backend.
Neither ordering nor exclusivity is implied: place a seam where it matches your threat model, and combine them for defence in depth — the gateway refuses what the model proposes, the source-side hook governs the agent you run, and the resource-side adapter catches whoever else arrives. The claim this diagram carries is that Reeflex fits the architecture you already have, rather than asking you to adopt one.