Condition language v1
A condition is a small JSON value that says what an agent is waiting for. CastHQ evaluates it against each change envelope on the target and fires when it becomes true. No code, no regular expressions, no JSON paths: every leaf names a field from a fixed list. Schema: /v1/condition.json.
Condition := Leaf | { "all": [Condition] } | { "any": [Condition] } | { "not": Condition }
Leaf :=
| { "change": Kind | [Kind], "semantic"?: [Semantic], "origin"?: [Origin] }
| { "change": "schedule", "moved_by": { "gt" | "gte": Duration }, "direction"?: "later" | "earlier" | "either" }
| { "change": "dispute", "opened"?: boolean, "loser_origin"?: [Origin] }
| { "status": { "becomes"?: Status, "from"?: Status, "in"?: [Status] } }
| { "availability": { "becomes"?: Availability, "from"?: Availability, "in"?: [Availability] } }
| { "lineup": { "includes": TopicUrn } }
| { "state": { "becomes"?: State, "in"?: [State] } }
| { "fact": "schedule" | "status" | "location" | "availability" | "lineup",
"confidence": { "becomes"?: Level, "becomes_at_least"?: Level, "at_least"?: Level, "drops_below"?: Level } }
| { "fact": "schedule" | "status" | "location" | "availability" | "lineup",
"sources": { "at_least": N } | { "becomes_at_least": N } }
| { "precision": { "becomes_at_least": Precision } }
| { "listed": { "becomes": boolean } }
| { "time": { "anchor": "start" | "end" | "deadline" | "on_sale", "offset": Duration,
"when_imprecise"?: "wait" | "earliest" | "latest", "tz"?: IanaZone } }
| { "time": { "at": DateTime, "tz"?: IanaZone } }
| { "dependency": { "at_risk"?: true, "parent_state"?: { "becomes": State } } }
| { "variant": { <axis>: Term | Term[] } }
| { "members": ({ "all" | "any": Level } | { "count_at_least": N, "of": Level }), "where"?: { "variant": … } }
| { "upstream": { "all" | "any": Level } | { "count_at_least": N, "of": Level } }
Level := status "in", availability "in", lineup "includes", state "in", confidence "at_least", precision "at_least",
sources "at_least", and "all" / "any" / "not" of those
The top-level object may carry "v": 1. Unknown keys and values are refused
with the path to the mistake.
| In plain words | Condition |
|---|---|
| when it is released | {"state": {"becomes": "released"}} |
| when the date is confirmed | {"fact": "schedule", "confidence": {"becomes_at_least": "confirmed"}} |
| when the date moves by more than 3 days | {"change": "schedule", "moved_by": {"gt": "P3D"}} |
| when it is cancelled or postponed | {"any": [{"status": {"becomes": "cancelled"}}, {"status": {"becomes": "postponed"}}]} |
| when it is held (a rain delay, a launch hold) | {"status": {"becomes": "delayed"}} |
| when it resumes after a hold | {"status": {"from": "delayed"}} |
| when it sells out | {"availability": {"becomes": "sold_out"}} |
| when my team is added to the line-up | {"all": [{"change": "lineup"}, {"lineup": {"includes": "urn:casthq:topic:…"}}]} |
| when venue, date and line-up are all confirmed | {"all": [{"fact": "location", "confidence": {"at_least": "confirmed"}}, {"fact": "schedule", "confidence": {"at_least": "confirmed"}}, {"fact": "lineup", "confidence": {"becomes_at_least": "confirmed"}}]} |
| 1 hour before tickets go on sale | {"time": {"anchor": "on_sale", "offset": "-PT1H"}} |
| when an event under a topic is announced | {"listed": {"becomes": true}} |
| when a community source contradicts the producer | {"change": "dispute", "opened": true, "loser_origin": ["observed"]} |
| when it moves later and the date is still confirmed | {"all": [{"change": "schedule", "semantic": ["moved_later"]}, {"fact": "schedule", "confidence": {"at_least": "confirmed"}}]} |
| when three independent sources agree on the date | {"fact": "schedule", "sources": {"becomes_at_least": 3}} |
| 2 hours before start | {"time": {"anchor": "start", "offset": "-PT2H"}} |
| not released by 1 March 2027, 09:00 Paris | {"all": [{"time": {"at": "2027-03-01T09:00:00+01:00"}}, {"not": {"state": {"in": ["released"]}}}]} |
when both have shipped (target events:a,b) |
{"members": {"all": {"state": {"in": ["released"]}}}} |
the first of them to ship (mode: once) |
{"members": {"any": {"state": {"in": ["released"]}}}} |
when everything upstream has ended (target chain:b) |
{"upstream": {"all": {"state": {"in": ["ended"]}}}} |
1 hour before my region's maintenance ends (target variants:p) |
{"all": [{"time": {"anchor": "end", "offset": "-PT1H"}}, {"variant": {"region": ["euw"]}}]} |
all regions back (target variants:p) |
{"members": {"all": {"state": {"in": ["ended"]}}}} |
all EU shards back (target variants:p) |
{"members": {"all": {"state": {"in": ["ended"]}}, "where": {"variant": {"region": ["euw", "eune"]}}}} |
Values
- Kinds:
created,schedule,status,title,location,links,published,merged,dispute,phase,confidence,dependency(an edge added or removed, or the event putat_riskby something it depends on),availability(ticket availability or the on-sale time moved),lineup(who takes part moved),recurrence(a series' rule moved), and the reservedexpectationandvariant(a variant parent gained or lost a variant). - Semantics (schedule moves):
scheduled,moved_earlier,moved_later,narrowed,widened,undated; dispute changes:opened,resolved; dependency changes:added,removed,at_risk. - Origins:
producer,observed,admin. - Statuses:
announced,scheduled,live,completed,cancelled,postponed,delayed(held, timing unknown, expected to resume; not moved, not called off). - Availability (tickets, as the sources say it):
not_on_sale,on_sale,few_left,sold_out,waitlist,unavailable. A fact, not a status: a sold-out event still happens, and its status and state do not move. CastHQ records it and never sells or holds tickets. - States:
announced,date_expected,scheduled,date_confirmed,live,released,ended,overdue,delayed,postponed,cancelled(see lifecycle state). - Levels, low to high:
disputed<uncertain<likely<confirmed(see Confidence). - Precisions, fine to coarse:
datetime,day,month,quarter,half,season,year,window,tba. - Time anchors:
startandendof the event;deadline, a commitment's due time (itsend; a timer firing reports it asend), refused on any other event;on_sale, when tickets go on sale (the event'son_sale_at), re-armed when a source moves it, event targets only. - Durations: ISO 8601 weeks, days, hours, minutes and seconds (
P3D,PT2H,P1DT12H). Years and months are refused: their length depends on the calendar. Atimeoffset is signed (negative is before the anchor). A relative schedule ({"relative": {"to", "anchor", "offset", "duration"}}in a schedule claim) reads the same durations: itsoffsetis signed, its optionalduration(how long the dependent lasts, so its end moves with its start) is positive.
Limits
| Limit | Value |
|---|---|
Nodes (leaves and the all / any / not around them) |
16 |
| Depth (a bare leaf is 1) | 4 |
Values in any list (including all / any children) |
8 |
| Durations | 1 minute to 400 days (an offset may also be zero) |
time leaves per condition |
1 |
members / upstream leaves per condition |
1 |
Events in a set target (events:) |
2 to 32 |
Evaluation
A condition is judged against one envelope at a time, and only against what
the envelope carries: its kind, semantic, old / new, delta,
provenance origin and the before / after snapshots stored on the change.
Never against today's state, so replaying a condition over history answers
"what would have fired at the time".
- Edge leaves (
becomes,from,becomes_at_least,drops_below,change,moved_by,listed,precision,time) are true at the change that makes them so, comparingbeforewithafter. A change with nobefore(created) counts as everything becoming whataftersays. - Level leaves (
in,at_least) are true on every change while the state holds, read offafter. Use them insideallto gate an edge. sourcescounts the independent sources that agree with a fact's shown value: distinct source domains, not claims. Ten scrapes of one site count once, a subdomain counts as its site, and a claim with no public link counts toward none.Nis 1 to 64.at_leastis a level,becomes_at_leastan edge: it fires once when the count reachesN, and a further claim from a site already counted does not fire it again. It is for "three of five feeds agree", not a numeric confidence: the confidence levels stay the contract. Unknown on changes recorded before snapshots carried source counts.availabilityreads the snapshots'availability;fact: "availability"is its confidence (two sources that disagree make itdisputed; where each sells does not count as disagreeing). Unknown on changes recorded before snapshots carried it; anavailabilitychange still says what it moved from and to.lineupreads the snapshots'lineup_urns, the topics the winning line-up names:includesis a level, true while the line-up names that topic (urn:casthq:topic:<uuid>, a team or an artist). Alone it holds on every change while true; gate it with{"change": "lineup"}in anallto fire once, when they are added.fact: "lineup"is the line-up's confidence (sources naming the same participants agree, whatever order or roles they give). Unknown on changes recorded before snapshots carried it; alineupchange still says what the line-up became.moved_byreadsdelta.start_seconds(ordelta.end_secondswhen the start has none).directiondefaults toeither.- Unknown is not false. A change recorded before snapshots existed has
no
before/after; a leaf that needs them is unknown there,notof unknown is unknown, and a condition fires only when the whole is true. timematches a timer firing, never a change. Timers fire at the anchor plus the offset, and again (with a new generation) when the date moves; one firing per anchor time, with an id (evf_<subscription>_t<group>.<generation>) that is the same by pull and by push.when_imprecisesays what to do while the anchor is coarser than a day:wait(the default) arms nothing until a day is known (a time of day, for offsets under a day);earliestandlatestarm against the start and the close of the window ("Q3 2027": 1 July, 1 October). A floating time ("09:00 local, everywhere") arms when the first zone reaches it, UTC+14, unlesstznames the zone to read it in. Level leaves beside atimeleaf are judged on the event's state when the timer fired.time.atis an absolute instant ("not by 1 March"): it fires once, at that instant, and never again, however the event's date moves. Give an offset orZ(2027-03-01T09:00:00+01:00), or a wall clock without one and atzto read it in. It must be in the future (on the test clock, in test mode) and at most 400 days ahead; otherwise the request is refused withinvalid_requestandparampointing at it (condition.all[0].time.at). Event and set targets; on a set it fires once for the whole set.members(set targets,events:a,b: 2 to 32 events you can list, in one world) andupstream(chain targets: everything the event depends on, not the event itself) aggregate a level condition over every member:all,any, orcount_at_leastN. On a change it fires when the aggregate goes from false to true, each member read as of that change from the change log, so a replay gives the same firings. Beside atime.atit is read as a level when the timer fires ("not both by D"). The firing carriesmembers:made_true(the members whose condition became true at it; withonceoverany, the first one) andstates(each member's lifecycle state).variantreads the event's variant value ({"region": "euw"}): every named axis must match, any listed term on it; an event that is not a variant never matches. It works on any target, and on a timer firing reads the variant that fired.variants:ptargets are a variant parent and its variants; a variant added later joins. A time leaf there fires once per variant, never for the parent; off the parent only its own news is read (its claimed status, title, location and links, and the variants it gains), not the span it takes from its variants.membersranges over the variants, andmembers.wherekeeps only those it names.
Initial
A subscription created when its condition is already true would otherwise
wait forever ("tell me when it is cancelled", asked after the cancellation).
initial decides:
fire_if_true: if the condition holds now, fire once at creation. The default for an event target whose condition has astatus,state, confidence orsourcesleaf. Each edge is read as the level it reaches (becomes Xholds while the value is X);changeandtimeleaves never hold.wait: only changes from now on fire. The default otherwise, and the only choice for topic targets (an initial firing there would be one per event).
Modes
once(the default with a condition): fires once, then the subscription issatisfied.continuous: fires at every change that makes the condition true.
Firing ids
A firing's id is deterministic: evf_<subscription>_<change>, or
evf_<subscription>_initial. Reading the same range twice gives the same
ids, so deduplicate on them.
Try one
POST https://api.casthq.co/v1/conditions/test replays a condition over an
event's or topic's history and returns the changes it would have fired at.
No sign-in needed for public events.