# 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](/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](/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 put `at_risk` by 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 reserved `expectation` and `variant` (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](/envelope#lifecycle-state)).
- **Levels**, low to high: `disputed` < `uncertain` < `likely` < `confirmed` (see [Confidence](/confidence)).
- **Precisions**, fine to coarse: `datetime`, `day`, `month`, `quarter`, `half`, `season`, `year`, `window`, `tba`.
- **Time anchors**: `start` and `end` of the event; `deadline`, a commitment's due time (its `end`; a timer firing reports it as `end`), refused on any other event; `on_sale`, when tickets go on sale (the event's `on_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. A `time` offset is signed (negative is before the anchor). A relative schedule (`{"relative": {"to", "anchor", "offset", "duration"}}` in a schedule claim) reads the same durations: its `offset` is signed, its optional `duration` (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, comparing `before` with `after`. A change with no
  `before` (`created`) counts as everything becoming what `after` says.
- **Level** leaves (`in`, `at_least`) are true on every change while the
  state holds, read off `after`. Use them inside `all` to gate an edge.
- **`sources`** counts 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. `N` is 1 to 64. `at_least` is a level,
  `becomes_at_least` an edge: it fires once when the count reaches `N`, 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.
- **`availability`** reads the snapshots' `availability`; `fact: "availability"` is
  its confidence (two sources that disagree make it `disputed`; where each
  sells does not count as disagreeing). Unknown on changes recorded before
  snapshots carried it; an `availability` change still says what it moved
  from and to.
- **`lineup`** reads the snapshots' `lineup_urns`, the topics the winning
  line-up names: `includes` is 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 an `all` to
  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;
  a `lineup` change still says what the line-up became.
- **`moved_by`** reads `delta.start_seconds` (or `delta.end_seconds` when the
  start has none). `direction` defaults to `either`.
- **Unknown is not false.** A change recorded before snapshots existed has
  no `before` / `after`; a leaf that needs them is unknown there, `not` of
  unknown is unknown, and a condition fires only when the whole is true.
- **`time`** matches 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_imprecise` says 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); `earliest` and `latest` arm 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, unless `tz` names the zone to read it in. Level leaves beside
  a `time` leaf are judged on the event's state when the timer fired.
- **`time.at`** is 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 or `Z` (`2027-03-01T09:00:00+01:00`), or a wall clock without one
  and a `tz` to 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
  with `invalid_request` and `param` pointing 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) and **`upstream`** (chain targets: everything the event depends
  on, not the event itself) aggregate a level condition over every member:
  `all`, `any`, or `count_at_least` N. 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 a
  `time.at` it is read as a level when the timer fires ("not both by D").
  The firing carries `members`: `made_true` (the members whose condition
  became true at it; with `once` over `any`, the first one) and `states`
  (each member's lifecycle state).
- **`variant`** reads 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:p` targets** 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. `members` ranges over the variants, and
  `members.where` keeps 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 a `status`, `state`,
  confidence or `sources` leaf. Each edge is read as the level it reaches (`becomes X`
  holds while the value is X); `change` and `time` leaves 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
  is `satisfied`.
- `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.
