Appearance
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 row
events.kind = "card_auth", source = "reap-card", vendor Singapore Airlines, MCC 4511, geo Singapore.Same shape any card auth producesDecision row
decisions.skill = "policy-enforcement", outcome_kind = "suggest", confidence 0.70, model identifier deterministic/policy-engine.No LLM in the verdict pathEvidencePolicy 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 bundle
loadActiveBundle 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 rules
evaluate 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

/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-entertainment
amount_cap · USD 200 on entertainment MCCs (5812/5813/5814/5921).cap-travel
amount_cap · USD 2,000 on travel MCCs (3xxx, 4111, 4112, 4511, 7011).block-cash
mcc_block · MCC 6010, 6011 (ATM / cash advance).block-gambling
mcc_block · MCC 7995.geo-sanctions
geo_block · KP, IR, SY, CU (per OFAC).receipt-200
receipt_required · USD 200 on any card_auth.after-hours-meals
after_hours · Meal MCCs outside 07:00–22:00 tenant-local.structuring-7d
structuring · ≥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
| Question | Answer |
|---|---|
| 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
| File | Why it matters |
|---|---|
src/skills/policy-enforcement/skill.ts | Loads 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.ts | Pure 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.ts | Typed PolicyRule discriminated union, PolicyBundle schema, and the demo bundle that ships with the seed. |
src/skills/policy-enforcement/nl-compiler.ts | Authoring-time only: turns natural-language policy text into typed rule rows. Never invoked by the engine. |
evals/policy-enforcement.jsonl | Golden cases for allow, flag, deny, receipt OCR floor, cross-currency cap math, APAC after-hours, structuring window, and injection-shaped inputs. |