Skip to content

Policy Enforcement — Worked Example

Policy enforcement is deterministic because a card or bill can be expensive, embarrassing, or regulated. The verdict needs to be reproducible without asking a model what it "meant." This page walks one live row through the engine, shows the rules it ran against, and explains the invariants that keep the verdict defensible months later.

Live row walkthrough

A senior employee charges Singapore Airlines SGD 4,029.50 on the corporate card for a business trip. The card authorization is allowed at the network — Reap is in the post-authorization review path here, not the decline path — but the spend is over the customer's single-trip travel cap and no receipt has been uploaded yet. The engine produces a flag verdict; the row lands in the controller's queue with both findings attached.

Singapore Airlines card authorization: Singapore dollar 4,029.50, event e55c7684-0349-4073-9884-33a37e3012cb.

What the user seesFlagged queue row. The trip is above the single-trip travel cap and needs a receipt. The card transaction is not blocked after the fact.Post-auth review path, not the decline path
Event rowevents.kind = "card_auth", source = "reap-card", vendor Singapore Airlines, MCC 4511, geo Singapore.Same shape any card auth produces
Decision rowdecisions.skill = "policy-enforcement", outcome_kind = "suggest", confidence 0.70, model identifier deterministic/policy-engine.No LLM in the verdict path
EvidencePolicy findings include the travel cap and receipt-required rules. Each finding carries a rule identifier, rule name, severity, and human-readable message.Replayable months later against the same bundle version
Reap implicationCard controls extend into explainable post-authorization review without a model in the auth path and without false declines.Reap-native rails for the controller surface

The 0.70 confidence on a flag is deliberate. allow rows are 0.99 (the rules clearly didn't fire), flag rows are 0.70 (the engine is sure the rules fired, but the meaning — is this a legitimate exception or a violation? — needs a human). The confidence number is about the engine's certainty in the evidence, not in the verdict. That distinction is what keeps the autonomy gate from auto-approving flags at high autonomy tiers.

Why this row landed as flag and not deny

The Singapore Airlines authorization hit two rules. Neither rule is a deny severity, so the strongest-verdict-wins arithmetic resolves to flag.

flagcap-travelSingle-trip travel cap. SGD 4,029.50 ≈ USD 3,028 > USD 2,000 cap. MCC 4511 (airlines) is on the travel list.
require_receiptreceipt-200No receipt event linked to this auth, and USD 3,028 ≥ USD 200 threshold.

max(severity) across all fired rules

If either rule had been deny (e.g. the geography were Iran instead of Singapore, triggering geo-sanctions), the engine would return deny regardless of how many other rules flagged. Verdict = max(severity) — computed in engine.ts:38-47, commutative across rule order.

Backend path

Five stages, all deterministic. Every stage either passes data to the next or returns a refusal — there is no recovery logic, no retry loop, and no model anywhere in the chain. The whole path takes under a millisecond for the eight-rule demo bundle.

1
Load eventThe card authorization lands in events with merchant, amount, currency, employee, and geography.
2
Load active bundleloadActiveBundle reads enabled tenant policy rules and pins the highest bundle version.
3
Join prior contextStructuring rule needs prior same-vendor txns in window. Receipt rule needs OCR-confirmed receipt events linked to this auth.
4
Evaluate rulesevaluate runs every active rule and collects findings; the strongest severity wins.
5
Write decisionA flag becomes suggest at 0.70; a deny becomes refuse(policy_conflict); a clean event auto-applies at 0.99.

Stages 1 and 2 are the I/O boundary — once the event and bundle are in memory, stages 3–5 are pure functions over typed inputs. That separation is what makes stage 4 trivially testable: feed it an event and a bundle, get back a verdict, no fixtures, no clock, no database. The structuring and receipt joins in stage 3 are the only "context" queries the engine needs; they're scoped to the smallest possible window (the structuring rule's windowDays, the current event's id) so the cost stays bounded as the tenant grows.

The active rule bundle

The /policies surface: Allowed 22, Flagged 18, Blocked 0 counters across the top; an NL compiler input that turns sentences like 'Flag any restaurant transaction over USD 200' into structured rules; a 'how Steward decides' verdict ladder showing Block → Hold → Receipt → Allow.
The /policies surface. The NL compiler is the operator-facing way to add a rule; the structured rule below is what the engine actually evaluates. The verdict ladder ("any deny wins" → "any flag holds" → receipt → allow) is the same max(severity) arithmetic the engine uses.

The demo tenant ships with eight rules in policies.ts. Each cell below is verbatim from the bundle — the rule name and threshold the engine actually evaluates against. Red rules can deny; gold rules flag or request a receipt. New rule types extend the PolicyRule discriminated union; the engine's switch on rule.kind is the only place that has to learn about them.

cap-entertainmentamount_cap · USD 200 on entertainment MCCs (5812/5813/5814/5921).
cap-travelamount_cap · USD 2,000 on travel MCCs (3xxx, 4111, 4112, 4511, 7011).
block-cashmcc_block · MCC 6010, 6011 (ATM / cash advance).
block-gamblingmcc_block · MCC 7995.
geo-sanctionsgeo_block · KP, IR, SY, CU (per OFAC).
receipt-200receipt_required · USD 200 on any card_auth.
after-hours-mealsafter_hours · Meal MCCs outside 07:00–22:00 tenant-local.
structuring-7dstructuring · ≥3 txns to same vendor summing to USD 5,000 in 7d.

Bundle version 2026.05 is stamped on every decision row this bundle produces. When a controller edits the bundle, the new version supersedes only forward — old decisions still reference the version that judged them.

Subtleties in the engine

Five details below are easy to get wrong and have been wrong in earlier drafts of this code. Each one is now defended by code and a golden eval case.

Strongest-verdict-wins is commutative

Rules are evaluated in bundle order, but the verdict does not depend on order. The engine collects every finding, then resolves: deny if any finding is deny, else flag if any finding fired, else allow. Re-ordering the bundle, or adding a new rule, cannot accidentally weaken an existing deny.

Receipt matching has an OCR floor

A receipt event satisfies receipt-200 only when its OCR confidence is ≥ 0.7 (skill.ts:47). A blurry phone-photo receipt with confidence 0.4 is not counted as evidence — better to flag than to silently accept a garbage scan as compliant.

Threshold lesson

0.7 is the smallest number that survives adversarial testing: 0.5 lets through too many smudged scans; 0.85 demands re-uploads from honest users. The golden set keeps a 0.69 case to lock the threshold.

After-hours is timezone-aware

An earlier version of after_hours used getUTCHours() unconditionally. An 18:00 Hong Kong dinner showed up as 10:00 UTC and never flagged. The current code resolves the hour in the tenant's IANA timezone (engine.ts:158-165) and falls back to UTC only when the rule omits a zone. The golden set carries an APAC after-hours case to catch any regression.

Past bug, now defended

This single line of code mis-aligned every after-hours rule by 7–8 hours for an APAC tenant. The fix is one Intl.DateTimeFormat call; the lasting protection is the golden case that pins it.

Cross-currency caps convert to USD once

Every amount cap converts both sides to USD-minor before comparing (engine.ts:98-100). A SGD 4,029.50 charge against a USD 2,000 cap doesn't compare 4,029.50 > 2,000 in raw units — it converts to USD-minor and compares 302,800 > 200,000. The FX table is the canonical one re-exported from lib/fx-rates.ts; there are no longer two divergent tables (a prior bug where VND drifted at the 4th decimal).

Bundle version pinning is deterministic

loadActiveBundle reads every enabled rule for the tenant, takes the maximum bundleVersion across them, and discards older enabled rules at lower versions. A partial migration that leaves some rules at v1 and others at v2 evaluates only against v2 — and the decision row stamps rulesVersion: "v2". A previous implementation read rows[0].bundleVersion from an unordered result; the version stamp was effectively undefined behaviour under partial migrations.

Audit replay depends on this

Six months from now, a controller pulling up this row and asking "which rules judged this?" needs a single, deterministic answer. The rulesVersion stamp is meaningless if a partial migration leaves it under-specified.

Decision row payload

The suggest outcome carries this payload to /queue:

ts
{
  verdict: "flag",
  findings: [
    {
      ruleId: "cap-travel",
      ruleName: "Single-trip travel cap",
      severity: "flag",
      message: "Single-trip travel cap: $3028 exceeds $2000 cap"
    },
    {
      ruleId: "receipt-200",
      ruleName: "Receipt required > USD 200",
      severity: "require_receipt",
      message: "Receipt required > USD 200: $3028 requires a receipt"
    }
  ]
}

Telemetry on the same row records modelId: "deterministic/policy-engine", rulesVersion: "2026.05", the rationale string ("Flagged: Single-trip travel cap, Receipt required > USD 200."), and an evidence array — one entry per finding. Replay against a future bundle version uses these stamps to know exactly which rules were live when the verdict was produced.

Why this is not a model path

QuestionAnswer
Why not ask a large language model whether the trip is allowed?Policy execution needs deterministic replay. The model can help author a policy later, but the live verdict should come from typed rules.
Where does ambiguity go?Ambiguous cases stay as flags for the controller. The row shows the rule evidence instead of pretending the system has final authority.
What does Reap get?A policy layer that can sit beside Reap Card controls, receipt capture, and approval workflows without weakening auditability.
What does the natural-language compiler do, then?At authoring time, nl-compiler.ts turns "no alcohol on client cards" into a typed rule row in the same bundle the engine reads. The model is upstream of the engine, never inside it.

Design patterns

Underneath the verdict model and the rule map, four classical patterns are at work. These are the same patterns that appear in any well-structured rule engine — calling them out makes the design legible to anyone who has built one before.

Rule EngineThe evaluator is a pure function over a typed rule bundle. Rules are first-class data rows; adding a new rule requires no code change to the engine itself.Policy logic is auditable and testable independently of the enforcement path.
SpecificationEach policy check (amount cap, MCC block, geo block, receipt required) is an isolated predicate that declares its own severity and human-readable message. Rules compose without coupling.New rule types slot in without touching existing ones.
Priority Queue / Verdict PrecedenceDeny outranks flag, flag outranks allow. The strongest verdict across all fired rules wins, and the outcome is deterministic regardless of rule evaluation order.No ambiguous compound verdicts; the output is always one of three typed states.
Versioned SnapshotEvery decision row records the policy bundle version active at evaluation time. Policy edits never mutate historical rows, and any transaction can be replayed against the bundle that produced its original verdict.Appeals and audits read a stable snapshot, not live policy state.

The first three patterns (Rule Engine, Specification, Priority Queue) are about how the verdict is computed. The fourth (Versioned Snapshot) is about how the verdict survives over time. Together they are why a controller can pull up a six-month-old flag and reproduce it exactly.

Files to inspect

FileWhy it matters
src/skills/policy-enforcement/skill.tsLoads the tenant bundle, joins prior vendor txns and linked receipts, runs the engine, maps verdicts to outcomes (allow → autoApply, flag → suggest, deny → refuse).
src/skills/policy-enforcement/engine.tsPure deterministic evaluator. This is the trust boundary — no I/O, no clock skew, no FX lookups outside the canonical table.
src/skills/policy-enforcement/policies.tsTyped PolicyRule discriminated union, PolicyBundle schema, and the demo bundle that ships with the seed.
src/skills/policy-enforcement/nl-compiler.tsAuthoring-time only: turns natural-language policy text into typed rule rows. Never invoked by the engine.
evals/policy-enforcement.jsonlGolden cases for allow, flag, deny, receipt OCR floor, cross-currency cap math, APAC after-hours, structuring window, and injection-shaped inputs.

Submission pack — Reap Chief Financial Officer Agent take-home