Decisions as a Service
Sign Up Now →Sign Up Now

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.

Pack rules availableHosted rules previewConcept + examples

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 formWhat it readsEvaluated byHow you call itStatus
Pack rules: built-in checks plus your regex and luhn_checksum rules in a playbookText: identifiers, card numbers, phrasesThe Decision Runtime, in your processengine.evaluate(...), or POST /v1/evaluate on the decision serviceAvailable
Hosted playbook hard_rules: conditions over typed stateFields of a state object: state.discount_pct > 20.0The hosted reasoning servicePOST /api/v1/reasoning/playbooks/turnPreview
The rule language (rules.dsl): business rules over typed facts, bound to an ontologyCase facts, conclusions of other rulesThe rule engine inside a digital workerThe worker's decision type, over its APIAvailable

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.

Your perimeter Application HelixorEngine evaluate() Decision service helixor-pack serve POST /v1/evaluate Pack rules built-in checks · regex · luhn_checksum Hosted Helixor platform Reasoning service playbook hard_rules · learning loop /api/v1/reasoning/playbooks/… Digital worker rule-language rulebook (rules.dsl) HTTPS
Pack rules decide inside your perimeter, with no network traffic. Hosted rules are reached over HTTPS, and whatever you send leaves your process.

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 rulesHosted playbook hard_rulesRule language
OutcomesThe pack's declared actions and its default_actionThe playbook's actions, with a verdict per rule and disposition bandsAPPROVE, HOLD, DECLINE, or NEED_DATA when missing data could change the decision
Which rule winsThe first fatal match, your rules before the built-in ones; then the first warningRules are evaluated in file order; put the most severe firstHighest @authority, then highest @priority, then the more conservative outcome. File order does not matter.
What you keepaction, triggers, remedy, receipt_hashVerdict, move, rule ids, run idDeciding and overridden rules, missing facts, obligations
Fails closedA pack that does not unseal or verify never startsA pack that fails validation or preflight is not admittedA 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#

DeploymentRules it runsLatencyData leaves your processChoose it whenStatus
Embedded in your runtime: HelixorEngine in your Python processPack rulesMicrosecondsNeverThe decision sits inside a service you run and its input arrives as text or fields.Available
Decision service: helixor-pack serve as a sidecarPack rules, evaluated in-process; HTTP is only the transportA local round tripOnly to the sidecarThe caller is not Python, or several services share one pack.Available
Generated embedded SDKs: helixor-sdk-gen targets for Java, C#, Rust and CPack rulesIn processNeverYou 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 keyHosted playbook hard_rulesMilliseconds and upYes, the state you sendRules over typed state that should improve from outcomes through the learning loop.Preview
Digital worker on the hosted platformA rule-language rulebook mounted on a decision typeMilliseconds and upYes, the case factsBusiness 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
BookingOutcomeWhy
52 m³, target 58HOLDF1 fires: the container is under-filled.
61 m³, target 58APPROVEF2 fires.
52 m³, no targetNEED_DATABoth 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
BookingFires
cost center R&D, cut-off 2026-12-24, service expressC1, C2, C3
cost center r&d, cut-off 2026-12-23, service ExpressC3 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 caseRule formWhy
Keep personal data, card numbers or internal codes out of prompts, responses and logsPack rules, embeddedEvery payload, microseconds, no data leaves the process. The built-in example pack does this.
Redact a language model's output as it streamsPack rules, in a streaming sessionThe 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 overrideHosted playbook hard_rulesOverrides become evidence, and the learning loop proposes a gated change.
Eligibility and credit policy: knockouts, risk signals, referralsThe rule languageChained 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 policyA domain pack's playbook, drafted and approved in StudioSource-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 canThe 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_templatesChange which action a rule selects
Roll back to any earlier versionChange 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 onUseStatus
Facts you can name and a policy you can stateRulesPack rules Available; hosted forms Preview
Rules that drift from what experts doRules plus the learning loopPreview
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 contextsOutcome memoryAvailable embedded
Choosing among permitted strategies from observed resultsAdaptive strategies; the playbook's rules still bound the choicePlanned
Allocation, routing or rostering under constraintsSolvers over the same decision statePreview
Drafting, summarizing or open-ended textA language model, with pack rules guarding what goes in and comes outAvailable

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-patternWhat goes wrongDo 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 rulesText 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 precedenceThe 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 = 50000Derived 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 < 650One 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-15Those 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_floorAnnotations are recorded, not substituted; the rulebook does not load.@param score_floor 650.
Defaulting a missing fact to zero before it reaches the rulesA 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 casesThe gate can only protect the boundaries it knows.Add a golden case for every boundary first. See Testing policies.
Changing a running version in placeDecisions 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 bodiestriggers[].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#

  1. Write it

    A playbook for pack rules or hosted hard_rules; a rules.dsl rulebook and its ontology for a digital worker. From a written policy, Studio can draft source-linked rules for review (source integration preview).

  2. Test it

    Golden cases for every boundary: one that passes, one that stops, one that is missing data. See Testing policies.

  3. 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.

  4. Run it

    Embedded, as a decision service, or hosted, as above. Keep the receipt or run id with every decision.

  5. 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.

Next steps#