Rules
The orishare.rule/v1 document: triggers, conditions, limits, actions, versions.
A rule is a JSON document validated on create and again on publish. Publishing writes an
immutable version; pausing, resuming and archiving change status without touching versions;
rollback publishes an older definition as a new version. The JSON Schema is available from the
rule-schema package and every validation error carries a rule_invalid.* code and a path.
{
"schema": "orishare.rule/v1",
"trigger": { "event": "order.paid" },
"conditions": {
"all": [
{ "path": "event.properties.total_cents", "op": "gte", "value": 5000 },
{ "path": "customer.tier", "op": "in", "value": ["silver", "gold"] }
]
},
"limits": [{ "id": "daily", "scope": ["customer"], "window": "day", "max": 3 }],
"actions": [
{
"type": "points.award",
"currency": "points",
"amount": "floor(event.properties.total_cents / 100)"
},
{ "type": "tier.evaluate", "program": "tp_…" }
]
}Paths
event.id | name | occurred_at | customer_id | properties.<key>, customer.id,
customer.attributes.<key>, customer.balances.<currency>, customer.tier,
customer.segments, customer.counters.<name>, now.
Operators
eq neq gt gte lt lte in not_in contains starts_with exists between within_last. Conditions
nest with all, any, not. A missing or mistyped value makes the leaf false and the trace
records the operand and its issue; unknown never matches, so "no email on file" is
{ "path": "customer.attributes.email", "op": "exists", "value": false }.
Limits
{ id, scope[], window?, max }: scope is any list of customer, event.* or
customer.* paths; window is none | day | week | month | year in the environment's timezone.
Limits are reserved before effects, all-or-nothing per rule, and survive rule edits that keep the
same id.
Actions
points.award / points.deduct (currency, amount as a number or a decimal expression,
optional confirm_after, expires_after, reason), reward.issue (reward),
tier.evaluate (program), attribute.set (attribute, value), webhook.emit (event,
optional payload), counter.release (rule, limit).
Amount expressions support + - * /, floor, ceil, round, min, max over numeric paths.
Amounts are whole minor units of the currency: a fractional result is rounded half to even to an
integer, and a currency's precision only says how a stored integer is displayed (precision 2
shows 125 as 1.25), it never scales an amount.
points.reverse takes back what an earlier event awarded, for refunds and cancellations:
{
"type": "points.reverse",
"currency": "points",
"match": { "event": "order.paid", "on": "event.properties.order_id" },
"fraction": "event.properties.refund_cents / event.properties.total_cents",
"reason": "Refund"
}match.event is the event whose awards are reversed and match.on an event.properties path
both events carry with the same value. Without fraction (0 to 1, of each award) or amount
(whole minor units, oldest award first) the whole award is reversed; give one or neither, not
both. An award that is still pending is voided (a partial refund keeps the rest pending with the
same confirmation date). A confirmed award gets a negative reverse ledger entry with
reversal_of; points already spent are reported as unrecoverable, or drive the balance
negative when the currency has negative_balance: allow. An award is never reversed for more
than its amount, and each refund event touches an award once. Every award touched sends a
points.reversed webhook. points.deduct is a plain debit and is not a refund.
webhook.emit sends a custom webhook. payload maps names to values, at most 32 of them:
{
"type": "webhook.emit",
"event": "order.rewarded",
"payload": {
"order_id": "event.properties.order_id",
"tier": "customer.tier",
"source": "loyalty",
"items": 3,
"tags": ["a", "b"]
}
}A string that starts with event. or customer., or is exactly now, is a path, resolved when
the rule runs with the resolver conditions use; every other string, number, boolean, null and
list of scalars is sent as written. Nested objects are refused, and so is a string that reads as a
path but is not one (customer.nickname): both fail validation with rule_invalid.webhook_payload.
A path whose value is missing, null or not a scalar is sent as null and named in the trace. The
resolved payload may not exceed 4096 bytes; beyond that the rule is skipped with invalid_operand
(webhook_payload_too_large). The delivery's data carries the result as payload. Without
payload the delivery carries the triggering event's properties instead.
counter.release gives one reservation of a limit back, for refunds of once-only awards:
{ "type": "counter.release", "rule": "rule_01...", "limit": "per_content" }rule and limit name the limit by rule id and limit id (l0, l1, ... when the limit has no
id); an unknown rule or limit fails validation with rule_invalid.action_reference. The limit's
own scope paths are resolved from the releasing event, so a content.refunded event carrying the
same content_id as the purchase releases exactly that customer's and content's reservation, and a
windowed limit releases the window the releasing event falls in. One is given back per event and
limit key, never below zero; a release of nothing is reported as released: 0. If the only remaining count for those scope values was reserved before the Phase 0 upgrade (it sits in a legacy lim| row, which is read as already consumed and never lowered), the release applies nothing and reports limit.release_unresolved: such a reservation cannot be given back and stays consumed until its window ends; the rule's other actions still run. If the event lacks
a scope value, or the named rule is not published, the rule is skipped with invalid_operand.
A rule applies all of its actions or none
A rule is one unit. When any of its actions is refused for this customer and event (for example a
program contribution for a customer not enrolled in an explicit-enrollment quest, a limit already
used, or an unresolvable operand), the whole rule is skipped: none of its actions apply, and the
event's trace names the rule and the reason (not_enrolled, limit_exhausted, invalid_operand, ...). Put actions
that must apply independently in separate rules, for example points for every customer in one rule
and quest progress in another.
Campaigns
A rule may belong to a campaign, which adds a schedule, a priority, an optional exclusive
stacking_group (highest priority wins), a budget shared across customers and an audience of
segments. Campaign checks happen before the rule's own conditions.
Rule language v2
orishare.rule/v2 adds explicit trigger kinds, source provenance, program projections and
scheduled evaluations. Existing v1 documents retain their stored definition and behavior.
A v2 customer event trigger is { "kind": "customer_event", "event": "order.paid" }.
A transition trigger names an immutable program key and a registered transition type:
{
"schema": "orishare.rule/v2",
"trigger": {
"kind": "program_transition",
"program": "reading_goal",
"type": "quest.completed"
},
"trust": { "minimum": "merchant_asserted" },
"actions": [{ "type": "webhook.emit", "event": "reading_goal.completed" }]
}The named program must exist in the environment and support that transition. Publication, resume and rollback validate references, installed execution capabilities and the combined program/rule transition graph. Cycles and paths longer than four steps are rejected. A stored version keeps its program-kind bindings; rollback cannot reinterpret an old key as a different kind. A program's own reward policy runs with its transition. Rules reacting to that transition run as separate durable follow-ups, so their failure cannot undo the committed source result.
GET /v1/capabilities requires rules:read. Its schema_supported action names describe
recognized syntax; execution_supported describes the actions this environment executes, which
in an environment with the mechanics installed includes the streak, badge, quest, leaderboard
and program actions (streak.contribute, streak.freeze_grant, badge.contribute,
badge.grant, quest.enroll, quest.contribute, leaderboard.contribute,
program.reverse_contribution).
Publishing a rule with an action missing from execution_supported returns rule_invalid with a
rule_invalid.capability_unavailable detail. Publication also checks the program the action
names: a rule may contribute to a streak, a badge, a tier, a leaderboard or a step of a quest
(Mechanics). A rule that names a program by key is published only
once that program exists (rule_invalid.action_reference otherwise); its draft can be saved
earlier. Legacy projections do not provide a second native effect writer.
item.grant (v2 only) gives the customer inventory items. item names an active item
definition and quantity is 1 to 1000 (default 1):
{ "type": "item.grant", "item": "xp_boost_15m", "quantity": 1 }It is executable in every environment, with or without mechanics, and a program reward slot
(quest completion, streak milestone, badge rank, level) accepts it too. An unknown or archived
item fails publication with rule_invalid.action_reference. Items are not monetary, so the
action needs no liability valuation; a client event grants items only when the rule's or the
program's trust floor admits it. A grant is made once per rule execution or reward slot, and the
event's effects report it as item.granted (item, quantity, entry_id). When the item's
max_quantity leaves less room than asked, what fits is granted and the rest is reported as
not_granted; when nothing fits, or the item was archived after publication, the effect is
item.not_granted with a reason and the rule's other actions still apply. Reversing an award
(points.reverse, a refund) does not take back items granted with it.
V2 reads typed paths such as source.kind, source.trust, source.effective_at,
source.accepted_at, transition.type, transition.before.*, transition.after.*,
schedule.key, schedule.due_at and customer.quests.<key>.steps.<step>.progress.
Unavailable projection coverage is different from an absent participation. A condition must not
interpret unavailable coverage as zero progress. In v2, an exists condition omits value;
v1 retains its existing operand syntax.
Source trust and liability
The server derives provenance from the authenticated admission path. Requests cannot set their
own source or trust. Customer authentication establishes identity; it does not establish that a
purchase happened. A rule may require trust: { "minimum": "merchant_asserted" }; the event
definition's minimum_trust separately controls admission. Transitions and schedules inherit
trust from their supporting evidence and never turn client activity into merchant proof.
Historical legacy_unknown evidence satisfies no trust floor.
Client activity can grant spendable value only under an explicit liability cap with a valuation; the default allowance is zero. Caps and rule limits are enforced atomically with effects. Recognition has separate abuse limits. A refund does not replenish ordinary client allowance; source-linked release requires an explicit verified-void policy.
Scheduled rules
An inactivity trigger runs when an admitted activity becomes old enough. It does not wait for a return event. This example awards a winback allowance after 30 days without another order:
{
"schema": "orishare.rule/v2",
"trigger": {
"kind": "schedule",
"schedule": {
"key": "lapsed_order",
"kind": "inactivity",
"event": "order.paid",
"offset": "30d"
}
},
"trust": { "minimum": "merchant_asserted" },
"limits": [{ "id": "yearly", "scope": ["customer"], "window": "year", "max": 1 }],
"actions": [{ "type": "points.award", "currency": "points", "amount": 300 }]
}Configure order.paid admission to require merchant evidence. The last admitted order supplies
the inactivity basis and its trust; the timer proves no new purchase. A newer admitted order
invalidates the older timer, including while its evaluation is pending. Initial seeding uses
retained events, so publishing an inactivity rule cannot infer activity older than retention.
Changing the event name requires evidence for the new event name.
every schedules accept a day, week or month cadence and a local at time. Materialization
covers the current period; it does not replay missed historical periods after downtime. Each
period freezes its customer membership once, then processes those customers independently.
Republishing a rule does not create a second allowance for that period. A period_deadline
schedule names a program and waits for its persisted period to finish finalization before
reading that period's projection. entitlement_expiry is recognized syntax but is unavailable
for publication until its execution capability is installed.
All schedule and transition work uses the durable program queue. Execution rechecks current
rule status, customer eligibility, source trust, coverage, availability, budgets and caps.
A deleted customer suppresses downstream work. Schedules, follow-ups and job ticks do not add
accepted-event or active-customer usage. A synchronous event response can include queued_work
and pending_followups; replay preserves the original committed result.
Yearly dates
A date_attribute schedule fires once per customer and calendar year on a customer date: a
collected birthday or anniversary, a key you define, or signup (the day the customer was
created). attribute names the date key, at is the local time (default 09:00), offset fires
that long before the day, and min_notice (default 30d) skips the year when the date was set or
changed with less notice. The time is local to the customer's time zone, else the environment's,
and 29 February fires on 28 February outside leap years. See
Birthdays and anniversaries.
{
"kind": "schedule",
"schedule": {
"key": "birthday",
"kind": "date_attribute",
"attribute": "birthday",
"at": "09:00"
}
}Purchase and refund examples
Example 04 requires a merchant server to look up purchase_id, verify that its owner is the
customer and that it grants the named content_id, then emit content.completed with
purchase_verified: true. Set that event definition's minimum_trust to merchant_asserted
and client_allowed to false, and enforce a schema requiring nonempty purchase and content
identifiers. The v2 rule also requires merchant trust. A raw client boolean or a customer's
unkeyed count of previous purchases is not proof of ownership of this content.
Example 05 reverses by event.properties.purchase_id, not content_id: two purchases of the
same content have separate identities. Both content.purchased and content.refunded must
carry that same purchase identifier, use merchant-only admission, and be emitted only after the
server verifies customer ownership and the purchase or refund. A refund event needs its own
stable event id for retries. Both examples are v2 rules with trust: { minimum: "merchant_asserted" };
Rule examples 04 and 05 shows them in full with their reversal
semantics. points.reverse finds the award through retained events; native program corrections
instead reference permanent contribution identity and survive raw-event retention.
Simulation
POST /rules/{id}/simulate accepts either its existing event input or a fact with kind
program_transition or schedule, never both. A simulated fact includes a source projection
and the corresponding transition or schedule fields. Simulation uses the same pure evaluator
and dependency snapshot without committing effects. Supplying a trust level for simulation
does not establish that trust for a live event or command.
Prompt: Python backend
A prompt to paste into a coding agent that sends trusted events and reads customer state from a Python backend with a secret key.
Rule examples 04 and 05
Purchase-gated completion and refund reversal in the canonical v2 rule shape, with the trust floor and reversal semantics stated.