Working with rules
Rules are how you state policy that must always hold: block a card number, escalate a discount above 20%, decline an application with a recent bankruptcy. This page shows where rules run in Helixor, how they become decisions, how to deploy them, when rules are the right tool and when another part of the platform is, and the mistakes to avoid.
Three places rules run#
Helixor has three rule forms. Each one is evaluated by a different part of the platform, and each one is at a different maturity. Choose by where the decision has to happen and what the rule has to read.
| Rule form | What it reads | Evaluated by | How you call it | Status |
|---|---|---|---|---|
Pack rules: built-in checks plus your regex and luhn_checksum rules in a playbook | Text: identifiers, card numbers, phrases | The Decision Runtime, in your process | engine.evaluate(...), or POST /v1/evaluate on the decision service | Available |
Hosted playbook hard_rules: conditions over typed state | Fields of a state object: state.discount_pct > 20.0 | The hosted reasoning service | POST /api/v1/reasoning/playbooks/turn | Preview |
The rule language (rules.dsl): business rules over typed facts, bound to an ontology | Case facts, conclusions of other rules | The rule engine inside a digital worker | The worker's decision type, over its API | Available |
The packaged runtime, 0.3.1, does not run your own hard_rules or the rule language. A compiled pack rejects your own hard_rules at compile time (UnsupportedPackDeclarationError) rather than sealing rules it would not execute. See What runs in 0.3.1.
How rules become a decision#
The Decision Runtime is the decision core of the platform. Rules are the policy it applies. In every form, a decision is taken from a closed set of outcomes the policy declares, and it comes back with the rules that produced it.
| Pack rules | Hosted playbook hard_rules | Rule language | |
|---|---|---|---|
| Outcomes | The pack's declared actions and its default_action | The playbook's actions, with a verdict per rule and disposition bands | APPROVE, HOLD, DECLINE, or NEED_DATA when missing data could change the decision |
| Which rule wins | The first fatal match, your rules before the built-in ones; then the first warning | Rules are evaluated in file order; put the most severe first | Highest @authority, then highest @priority, then the more conservative outcome. File order does not matter. |
| What you keep | action, triggers, remedy, receipt_hash | Verdict, move, rule ids, run id | Deciding and overridden rules, missing facts, obligations |
| Fails closed | A pack that does not unseal or verify never starts | A pack that fails validation or preflight is not admitted | A rulebook with any rule the engine cannot evaluate does not load |
Precedence differs between forms. In a playbook you order rules, most severe first. In the rule language you state precedence: @authority, @priority, or a NOT guard such as AND NOT HARD_STOP.
Ways to run rules#
| Deployment | Rules it runs | Latency | Data leaves your process | Choose it when | Status |
|---|---|---|---|---|---|
Embedded in your runtime: HelixorEngine in your Python process | Pack rules | Microseconds | Never | The decision sits inside a service you run and its input arrives as text or fields. | Available |
Decision service: helixor-pack serve as a sidecar | Pack rules, evaluated in-process; HTTP is only the transport | A local round trip | Only to the sidecar | The caller is not Python, or several services share one pack. | Available |
Generated embedded SDKs: helixor-sdk-gen targets for Java, C#, Rust and C | Pack rules | In process | Never | You need the runtime inside a non-Python process. The bindings need their separately provisioned runtime artifacts. | Available |
| Hosted reasoning service, over HTTPS with an API key | Hosted playbook hard_rules | Milliseconds and up | Yes, the state you send | Rules over typed state that should improve from outcomes through the learning loop. | Preview |
| Digital worker on the hosted platform | A rule-language rulebook mounted on a decision type | Milliseconds and up | Yes, the case facts | Business rules with owners, authorities and required facts, inside a worker that owns the loop. | Available You build and deploy workers in the console or with the agent command-line tools; console access is granted on request. |
Embedded, the rules run wherever your code runs:
from helixor_runtime import HelixorEngine
engine = HelixorEngine.load_pack("acme.project_codes.v1.hxpack",
license_file="~/.helixor/helixor.hxlic")
print(engine.evaluate("Status of PROJ-ZEUS-9X?").action)
As a network service, the same pack answers HTTP:
helixor-pack serve --pack acme.project_codes.v1.hxpack \
--license ~/.helixor/helixor.hxlic \
--host 127.0.0.1 --port 18734
curl -s http://127.0.0.1:18734/v1/evaluate \
-H 'Content-Type: application/json' \
-d '{"text": "Status of PROJ-ZEUS-9X?"}'
Treat any non-200 response, timeout or connection failure from the service as a block, never as "allow". Tokens, TLS and network policy are in Serving decisions, and manifests in Deployment patterns.
The usual layout combines them: decide embedded on every payload, and send to a hosted service only the cases the embedded decision permits and cannot settle. See Escalating to hosted reasoning.
Pack rules and hosted rules, with business examples#
Guard text with pack rules#
Business example. An engineering firm uses internal project code names (PROJ-ZEUS-9X) and must never let them reach a supplier, whether in an email draft, a chatbot reply or a log line. A pack rule checks every outgoing payload in-process, in microseconds, so the check costs nothing to run everywhere.
A regex rule blocks or redacts a pattern on every payload, next to the built-in checks. action: block returns block_<id> with severity FATAL; action: redact returns redact_<id> and replaces each match with [REDACTED_<ID>].
pack_id: acme.project_codes.v1
version: 1.0.0
actions: [permit_clean_payload, block_project_code, block_glba_ssn_leakage,
block_pci_dss_pan_leakage, block_hipaa_phi_leakage, redact_and_permit_contact_pii]
rules:
- id: project_code
type: regex
pattern: '\bPROJ-[A-Z]+-\d[A-Z]\b'
action: block
default_action: permit_clean_payload
Your own pack rules need a Developer license (Tier 1). Build them step by step in Add custom rules.
Decide over typed state with hosted hard rules#
A hosted playbook states each rule as a condition over the state you send, the action it selects and a verdict. The rules below cap a rep's discount and escalate large accounts:
"hard_rules": [
{
"id": "RULE-MAX-DISCOUNT-20",
"description": "Rep discount ceiling is 20%; above it the VP decides",
"condition": "state.discount_pct > 20.0 and state.user_role != 'vp_sales'",
"action": "escalate_to_vp",
"verdict": "refuse"
},
{
"id": "RULE-LARGE-ACCOUNT",
"description": "Accounts above 250k a year get an executive sponsor",
"condition": "state.annual_revenue > 250000",
"action": "schedule_exec_sponsor",
"verdict": "queue"
}
]
Business example. A software company caps a rep's renewal discount at 20%. Vice presidents keep approving 23% for three-year renewals, and each override is recorded. These are the rules the learning loop can improve: it proposes the exception the overrides point to, checks it against golden cases, and waits for a second person to admit it. See Rules or the learning loop.
Rule-language concepts, with business examples#
The rule language is for business decisions over typed facts. Each concept below starts with what it is. It then shows a business situation that needs it, from a fictional freight-forwarding desk that releases shipments (approve the booking, hold it for a person, or decline it), and explains why the language behaves the way it does. Every rule and outcome here was run through the rule engine. In production, a rulebook runs in a digital worker Available. The full grammar is in the Rule language reference, and the credit-line example runs a 35-rule rulebook end to end.
Every rule has an owner and an authority#
A rule is one line, ID: IF conditions THEN conclusions, with annotation lines before it that say who owns it, where its force comes from, which facts it cannot run without, and what it means in plain English.
Business example. A credit policy team owns its knockout rules, and an auditor asks why an application was declined. The rule answers with its owner, its authority, the facts it read and a sentence the auditor can read:
@authority policy @owner credit-policy @severity hard @category knockout @priority 100 @required_fact_mode explicit @required_facts [HAS_CREDIT_SCORE] @english A credit score below 550 is grounds for decline. RKO03: IF FILE_COMPLETE AND HAS_CREDIT_SCORE < 550 THEN DECLINE_GROUNDS_CREDIT
Why it works this way. A decision has to be explained to someone outside the team that wrote the rule. Keeping ownership, authority and intent on the rule itself means the explanation travels with the decision report.
Comparing with a threshold#
A comparison reads a case fact (HAS_NAME) and compares it with a number, a code, a parameter or another fact.
Business example. Each customer contract sets its own minimum container fill. Under one contract a 40-foot container must carry at least 58 cubic metres, and under another 50. The target arrives with the booking, so it cannot be a number written into the rule.
@required_facts [HAS_CONTAINER_CBM, HAS_MIN_CBM_TARGET] F1: IF HAS_CONTAINER_CBM < HAS_MIN_CBM_TARGET THEN UNDER_FILLED AND RECOMMEND_HOLD F2: IF HAS_CONTAINER_CBM >= HAS_MIN_CBM_TARGET THEN RECOMMEND_APPROVE
| Booking | Outcome | Why |
|---|---|---|
| 52 m³, target 58 | HOLD | F1 fires: the container is under-filled. |
| 61 m³, target 58 | APPROVE | F2 fires. |
| 52 m³, no target | NEED_DATA | Both rules are blocked on HAS_MIN_CBM_TARGET; nothing is guessed. |
Why it works this way. A code such as HIGH has no order, so after <, <=, > or >= the right-hand side is always read as a fact. The bare name MIN_CBM_TARGET gives the same result: it reads the case fact HAS_MIN_CBM_TARGET, and reports it missing when the booking has none. A missing target becomes NEED_DATA, because a hold or an approval against a target nobody supplied would be a guess.
Computing a value in a condition#
Either side of a comparison can compute with + - * / % **, parentheses and ABS, MIN, MAX.
Business example. The desk holds any booking whose margin is under 8% of the sell rate, so a commercial manager can review it.
M1: IF HAS_SELL_RATE - HAS_BUY_RATE < HAS_SELL_RATE * 0.08 THEN THIN_MARGIN AND RECOMMEND_HOLD M2: IF HAS_SELL_RATE - HAS_BUY_RATE >= HAS_SELL_RATE * 0.08 THEN RECOMMEND_APPROVE
A sell rate of 2,400 against a buy rate of 2,250 leaves 150, under the 192 that 8% requires, so the booking is held. Against a buy rate of 2,000 it leaves 400, and the booking is approved.
Why it works this way. Arithmetic follows one fixed order (* before -, left to right), so a reviewer reads the rule the way the engine does. When an input cannot be computed, the case stops instead of guessing. A lane check such as HAS_LANE_REVENUE / HAS_TEU_SHIPPED < 900 with zero TEU shipped raises division_by_zero, and a revenue of "n/a" from a bad feed raises arithmetic_on_text. Infinite, complex and overflowing values fail the same way. A person decides that case; it is never silently approved or declined.
Ranges#
A range is two comparisons joined with AND. A condition holds exactly one comparison.
Business example. Containers from 20 to 26 tonnes gross pay a heavy-lift surcharge, and anything heavier is declined.
W1: IF HAS_GROSS_WEIGHT_KG >= 20000 AND HAS_GROSS_WEIGHT_KG < 26000 THEN HEAVY_SURCHARGE W2: IF HAS_GROSS_WEIGHT_KG >= 26000 THEN OVERWEIGHT AND RECOMMEND_DECLINE
21,500 kg gets the surcharge, 27,100 kg is declined, and 18,000 kg gets neither.
Why it works this way. The tempting form, 20000 <= HAS_GROSS_WEIGHT_KG < 26000, is not one condition, so the rulebook does not load. The error, chained_comparison, gives the fix to paste back in: 20000 <= HAS_GROSS_WEIGHT_KG AND HAS_GROSS_WEIGHT_KG < 26000. Refusing to load is deliberate: a band that silently never matches charges nobody the surcharge, and nobody notices.
Codes, exact text and dates#
A code after == or != is either unquoted, which compares ignoring case and punctuation, or quoted, which compares as exact text.
Business example. Bookings charged to the R&D cost center go to a separate budget, and bookings with the holiday cut-off date need priority handling. Service levels arrive from several systems as express, Express or EXPRESS.
C1: IF HAS_COST_CENTER == "R&D" THEN RD_BUDGET C2: IF HAS_CUTOFF_DATE == "2026-12-24" THEN HOLIDAY_CUTOFF C3: IF HAS_SERVICE_LEVEL == EXPRESS THEN PRIORITY_HANDLING
| Booking | Fires |
|---|---|
cost center R&D, cut-off 2026-12-24, service express | C1, C2, C3 |
cost center r&d, cut-off 2026-12-23, service Express | C3 only |
Why it works this way. The same service level arrives spelled differently from different systems, so an unquoted code ignores case. A cost center or a date written by another system has to match character for character, so a quoted code is exact. Unquoted, R&D would read as the operator & and 2026-12-24 as a subtraction, so both stop the load (expression_unsupported, unquoted_date), and each error says to quote the value.
A name that is both a code and a fact#
After == or !=, a bare name is a code. The same rulebook may not also use that name as a fact.
Business example. One rule notifies the customs broker when a shipment's status is the code CUSTOMS_HOLD. Another rule, written months later by another team, derives a flag of the same name when a container has dwelt more than three days.
S1: IF HAS_SHIPMENT_STATUS == CUSTOMS_HOLD THEN NOTIFY_BROKER S2: IF HAS_DWELL_DAYS > 3 THEN CUSTOMS_HOLD
The rulebook does not load: equality_against_fact_name, "CUSTOMS_HOLD after == is read as a code, but this rulebook also uses CUSTOMS_HOLD as a fact".
Why it works this way. Once the name means two things, a reader cannot tell which one S1 means, and the engine will not choose for them. Write HAS_SHIPMENT_STATUS == "CUSTOMS_HOLD" for the status code (quoted, it must match exactly), or rename the derived flag (LONG_DWELL). To compare two case facts, write HAS_A == HAS_B.
Chained conclusions#
A rule's conclusion is a fact that other rules can read, so a decision can be built from named intermediate findings.
Business example. Several unrelated problems must each stop a shipment: dangerous goods without a UN number, or a party on a screening list. Each problem derives its own finding, every finding derives HARD_STOP, and one rule declines on HARD_STOP.
G1: IF HAS_DANGEROUS_GOODS == 1 AND NOT KNOWN_UN_NUMBER THEN DG_PAPERWORK_MISSING G2: IF HAS_SANCTIONED_PARTY == 1 THEN SCREENING_HIT G3: IF DG_PAPERWORK_MISSING THEN HARD_STOP G4: IF SCREENING_HIT THEN HARD_STOP G5: IF HARD_STOP THEN RECOMMEND_DECLINE G6: IF HAS_BOOKING_CONFIRMED == 1 AND NOT HARD_STOP THEN RECOMMEND_APPROVE
Dangerous goods without a UN number fire G1, G3 and G5: DECLINE. The same booking with UN number UN1203 is approved by G6.
Why it works this way. Each finding is named, so the decision report says why the shipment stopped, not only that it did. A new stop reason is one more rule that derives HARD_STOP; the approval rule does not change. File order does not matter: G6 waits until everything that can derive HARD_STOP has run.
Required facts and completeness#
A rule lists the facts it cannot run without. KNOWN_X tests whether the case has a value for X, whatever the value is.
Business example. Customs entries above 10,000 in estimated duty go to a broker. A decision needs the tariff code, the declared value and the duty rate.
K1: IF KNOWN_HS_CODE AND KNOWN_DECLARED_VALUE THEN FILE_COMPLETE K2: IF FILE_COMPLETE AND HAS_DECLARED_VALUE * HAS_DUTY_RATE > 10000 THEN BROKER_REVIEW AND RECOMMEND_HOLD K3: IF FILE_COMPLETE AND HAS_DECLARED_VALUE * HAS_DUTY_RATE <= 10000 THEN RECOMMEND_APPROVE
A declared value of 250,000 at 5% is 12,500 in duty, so the entry is held for the broker. At 120,000 the duty is 6,000, and it is approved. With only the tariff code, the outcome is NEED_DATA, naming HAS_DECLARED_VALUE and HAS_DUTY_RATE.
Why it works this way. Releasing a shipment on incomplete paperwork is worse than asking for the missing piece, so a missing required fact blocks the rule and the outcome names what to collect. KNOWN_X is a presence test, not a value, so KNOWN_HS_CODE > 0 stops the load (known_in_comparison); compare HAS_HS_CODE instead. In the same way, HAS_HAZMAT > TRUE is reserved_word_ordering, because true has no order; write HAS_HAZMAT == TRUE, or test HAS_HAZMAT on its own.
Obligations and prohibitions#
A conclusion can state something that must or must not be done: OBLIGATION(action) or PROHIBITION(action).
Business example. A held customs entry must be sent to the broker, and a screening hit must not be disclosed to the shipper.
O1: IF HAS_DECLARED_VALUE * HAS_DUTY_RATE > 10000 THEN RECOMMEND_HOLD AND OBLIGATION(notify_customs_broker) O2: IF HAS_SANCTIONED_PARTY == 1 THEN RECOMMEND_DECLINE AND PROHIBITION(disclose_screening_result)
When both fire, the outcome is DECLINE, and the report carries the obligation notify_customs_broker and the prohibition disclose_screening_result for the system that acts on it.
Why it works this way. A decision often comes with duties attached. Stating them as conclusions puts the duty under the same review, versioning and audit trail as the decision, instead of leaving it in the code that calls the rules.
Conflicts between rule owners#
When rules recommend different outcomes, the one with the higher @authority wins, then the higher @priority, then the more conservative outcome (decline over hold over approve).
Business example. Dangerous goods without a UN number may not ship. A sales rule approves gold-tier customers quickly. A gold customer books dangerous goods with no UN number.
@authority regulation @priority 100 D1: IF HAS_DANGEROUS_GOODS == 1 AND NOT KNOWN_UN_NUMBER THEN RECOMMEND_DECLINE @authority guidance @priority 10 D2: IF HAS_CUSTOMER_TIER == GOLD THEN RECOMMEND_APPROVE
Both rules fire, and the outcome is DECLINE on authority: regulation outranks guidance. The report keeps both rules and says which one won and why.
Why it works this way. Real rulebooks collect rules from different owners, such as compliance, operations and sales. Resolving conflicts by declared authority means the result never depends on which rule happens to come first in the file, and a sales rule can never override a regulation by accident.
Use cases#
| Use case | Rule form | Why |
|---|---|---|
| Keep personal data, card numbers or internal codes out of prompts, responses and logs | Pack rules, embedded | Every payload, microseconds, no data leaves the process. The built-in example pack does this. |
| Redact a language model's output as it streams | Pack rules, in a streaming session | The runtime holds back a short look-ahead window to catch a value split across chunks. See Streaming. |
| Discount, pricing or approval limits that experts sometimes override | Hosted playbook hard_rules | Overrides become evidence, and the learning loop proposes a gated change. |
| Eligibility and credit policy: knockouts, risk signals, referrals | The rule language | Chained conclusions, authority between policies, required facts and obligations, with an owner on every rule. |
| Routing a ticket or a purchase to the right reviewer from a written policy | A domain pack's playbook, drafted and approved in Studio | Source-linked rules, explicit tests and a separate approval. Source integration preview; see Domain packs. |
Rules or the learning loop#
The learning loop is not an alternative to rules. It improves hosted playbook rules. It records outcomes and expert overrides, classifies each gap as a data defect, a policy defect or expected performance, and for policy defects proposes changes to the rules you wrote (POST /api/v1/reasoning/playbooks/evolve). A proposal passes only when it resolves at least one recorded gap and breaks none of your golden cases, and it changes nothing until a different person admits it as a new version.
| The loop can | The loop cannot |
|---|---|
| Move a numeric threshold in a hard rule (by −5%, −2%, +2% or +5%) | Add or remove a rule |
Add an exception clause you declared in the pack's exception_templates | Change which action a rule selects |
| Roll back to any earlier version | Change pack rules or a rule-language rulebook |
So write the policy as rules first, declare the exceptions a rule may grow, and keep a golden case for every boundary you care about. The loop then keeps the thresholds honest. Walk through it in Close the learning loop and Propose rule changes from outcomes.
Rules or another part of the platform#
| The decision depends on | Use | Status |
|---|---|---|
| Facts you can name and a policy you can state | Rules | Pack rules Available; hosted forms Preview |
| Rules that drift from what experts do | Rules plus the learning loop | Preview |
| Reading evidence: is this activity explained by a change record? | The reasoning service, which answers or abstains with a calibrated p_correct. See Ask the reasoning service. | Preview |
| Which claims have held up in which contexts | Outcome memory | Available embedded |
| Choosing among permitted strategies from observed results | Adaptive strategies; the playbook's rules still bound the choice | Planned |
| Allocation, routing or rostering under constraints | Solvers over the same decision state | Preview |
| Drafting, summarizing or open-ended text | A language model, with pack rules guarding what goes in and comes out | Available |
A rule is the right tool when you can say what a correct decision is. When nobody can, no rule and no learning loop can find it; send those cases to the reasoning service or to a person. The trade-offs against other kinds of systems are in Choosing an approach.
Anti-patterns#
| Anti-pattern | What goes wrong | Do this instead |
|---|---|---|
| A rule that judges meaning: "the customer sounds upset", "this looks like fraud" | A pattern or threshold stands in for judgement, and errs with confidence. | Ask the reasoning service, which can abstain, or use a calibrated head as one input to a rule. |
| Treating an unreachable decision service as "allow" | An outage silently disables the policy. | Treat any non-200, timeout or connection failure as a block. |
Business logic over fields written as regex rules | Text patterns cannot compare values or chain conclusions. | Use hosted hard_rules or the rule language for conditions over typed facts. |
| Relying on file order in a rulebook for precedence | The rule language ignores file order, so two recommendations can both fire. | Guard with NOT (AND NOT HARD_STOP), or set @authority and @priority. |
Computing a value in a rule: THEN APPROVED_LIMIT = 50000 | Derived facts are flags; the rulebook does not load. | Compute values before the case reaches the rules, or compute inside a comparison. |
A chained comparison: 550 <= HAS_CREDIT_SCORE < 650 | One condition holds one comparison. | Split it with AND: HAS_CREDIT_SCORE >= 550 AND HAS_CREDIT_SCORE < 650. |
An unquoted code with &, |, !, ~ or ^, or an unquoted date: HAS_DEPT == R&D, HAS_DUE == 2026-12-15 | Those characters are operators, and a date reads as subtraction; the rulebook does not load. | Quote it: == "R&D", == "2026-12-15". A quoted value matches that exact text, including case. Compare dates with == and != only. |
Using a custom annotation as a variable: @score_floor 650, then < score_floor | Annotations are recorded, not substituted; the rulebook does not load. | @param score_floor 650. |
| Defaulting a missing fact to zero before it reaches the rules | A missing credit score becomes a low score, and the case is declined instead of completed. | Leave it missing and declare it in @required_facts, so the outcome is NEED_DATA. |
| Asking the learning loop for proposals without golden cases | The gate can only protect the boundaries it knows. | Add a golden case for every boundary first. See Testing policies. |
| Changing a running version in place | Decisions already made can no longer be reproduced. | Release a new pack or rulebook version; every result names the version that produced it. |
| Logging the decision service's response bodies | triggers[].matched_items holds the matched values, which may be the sensitive data itself. | Log the action, rule ids and receipt_hash. See Receipts. |
Life cycle of a rule#
- Write it
A playbook for pack rules or hosted
hard_rules; arules.dslrulebook and its ontology for a digital worker. From a written policy, Studio can draft source-linked rules for review (source integration preview). - Test it
Golden cases for every boundary: one that passes, one that stops, one that is missing data. See Testing policies.
- Release it
Compile a pack for your license (Packs), or admit a hosted playbook as a numbered version. A rule the platform cannot evaluate stops the release.
- Run it
Embedded, as a decision service, or hosted, as above. Keep the receipt or run id with every decision.
- Improve it
Report outcomes and overrides. For hosted rules, the learning loop proposes gated changes. Every change is a new version you can roll back.