Files
website/server/src/engagement/conditions.js
wtclaude 8e03497eb3
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m24s
feat(events): schema, CRUD and the core action registry (Phase 1)
EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.

**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.

Schema (`db/schema.sql`, append-only):
  event_series, event_definitions, event_versions, event_runs,
  event_run_steps, event_run_log. The four that need a writer —
  event_action_settings, event_run_budget, event_run_resources,
  event_run_participants — arrive with the phases that give them one.

Registry (`modules/registries.js` + `config/coreEventActions.js`):
  registerEventActions staging and commit, with its own id namespace, the
  closed risk and reversibility sets, revert() required iff and only iff
  reversible: 'ledger', a bounded budgetMs and a param shape whose every
  entry needs a type and an example. perform/revert/cost are stripped from
  everything the catalog serves. Core declares core.announce, core.wait and
  core.cue through the same staging area a module will use.

  It is reachable ONLY by registerCore(): loader.js builds its own api facade
  and has no method that delegates here, so no module can call it and
  MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.

Surface (13 routes under /api/v1/admin/events):
  Reads staff-wide; publish, archive and run creation admin-only from this
  phase per EVENTS.md §N2, even though the switchboard they will consult does
  not exist yet — a button that is admin-only later and open now is a gate
  nobody notices was missing. The live run controls and `verify` are absent
  rather than stubbed, because nothing is in flight yet.

Four things the build settled, all recorded in docs:
  - event_definitions gained a `spec` column. A draft's working copy cannot
    be an event_versions row: that table is immutable and a run pins one.
  - The spec validator must accept its own output. It added `actionVersion`
    and `dormant` and then refused them as unknown keys, which would have made
    the second save of any definition — and publish's re-validation —
    impossible. A test caught it; both are now accepted and recomputed.
  - A param's `example` is required, optional params included, matching
    registerEventTriggers. It is the authoring form's placeholder.
  - Two routes the §API-surface table did not name: GET /admin/events/:id and
    GET /admin/events/series.

Core's three perform() bodies answer { ok: false, retry: false } rather than
{ ok: true }: `ok: true` on an action that did nothing is a recorded world
change that did not occur, which is the exact mistake §F's failure default
exists to prevent.

`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.

Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).

Docs: RunicGateway/docs#209

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 23:29:07 -05:00

266 lines
11 KiB
JavaScript

// ── Rule conditions — a predicate over a trigger's DECLARED variables ───────
//
// ENGAGEMENT.md §4.5, Phase 4a. `engagement_rules.conditions` is the half of a
// rule that decides *whether* this particular firing is interesting: "only when
// decayStatus is IDOC", "only for threads in this Team". Without it every rule is
// all-or-nothing per trigger, and an operator's only way to narrow is to ask a
// module author for a second trigger.
//
// **It is validated against the declaration, not against a payload.** A condition
// naming a variable the trigger does not declare is refused at SAVE, with the
// variable named, for the same reason §4.3 gives the template editor: a predicate
// that silently reads `undefined` is a rule that silently never fires (or always
// does), and the day you find out is the day the mail did not go.
//
// **The grammar is small and closed on purpose.** No arbitrary expressions, no
// arithmetic, no regex. An operator composes and/or/not over comparisons of one
// declared variable against a literal, and every operator here is one a rule
// editor can render as a dropdown. Anything that needs more than this is asking
// for a condition the module should have declared as a variable.
//
// Nothing in this file reaches the database or the network.
const registries = require('../modules/registries')
// Comparison operators, grouped by what they may be applied to. The grouping is
// the whole of the type check: `gt` on a boolean and `startsWith` on an int are
// both refused at save rather than quietly answering false forever.
const OPERATORS = {
eq: { label: 'is', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
ne: { label: 'is not', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 1 },
in: { label: 'is one of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
nin: { label: 'is none of', types: ['string', 'int', 'float', 'url'], arity: 'list' },
gt: { label: 'is greater than', types: ['int', 'float', 'datetime'], arity: 1 },
gte: { label: 'is at least', types: ['int', 'float', 'datetime'], arity: 1 },
lt: { label: 'is less than', types: ['int', 'float', 'datetime'], arity: 1 },
lte: { label: 'is at most', types: ['int', 'float', 'datetime'], arity: 1 },
contains: { label: 'contains', types: ['string', 'url'], arity: 1 },
startsWith: { label: 'starts with', types: ['string', 'url'], arity: 1 },
// The one operator that takes no value: "the emit carried this variable at
// all". It is the honest way to write a rule about an OPTIONAL variable, and
// without it `ne` would have to double as a presence test and get it wrong
// (an absent variable is not "not equal to X"; it is absent).
present: { label: 'is present', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
absent: { label: 'is absent', types: ['string', 'int', 'float', 'boolean', 'datetime', 'url'], arity: 0 },
}
const BOOLEAN_OPS = ['and', 'or', 'not']
// A list literal an operator may type. Bounded because it is stored in a JSON
// column an admin can write, and an unbounded IN list is an unbounded predicate
// evaluated on every event.
const MAX_LIST = 50
// Depth of the and/or/not tree. Three levels is more nesting than any rule
// editor should offer; the bound is here so a hand-written JSON body cannot
// recurse this evaluator into a stack overflow on the emit path.
const MAX_DEPTH = 5
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
/**
* Check one literal against the declared type of the variable it is compared to.
*
* `datetime` accepts anything `Date` parses and is normalised to an ISO string,
* which is what `engagementEmit.coerce` does to the payload side — so both sides
* of every comparison are the same representation of a moment, and a lexical
* `<` on two ISO strings is a chronological one.
*/
function checkLiteral(type, raw) {
switch (type) {
case 'string':
case 'url':
return typeof raw === 'string' ? { value: raw } : { error: 'expected a string' }
case 'int':
return Number.isInteger(raw) ? { value: raw } : { error: 'expected an integer' }
case 'float':
return typeof raw === 'number' && Number.isFinite(raw)
? { value: raw }
: { error: 'expected a finite number' }
case 'boolean':
return typeof raw === 'boolean' ? { value: raw } : { error: 'expected a boolean' }
case 'datetime': {
const d = raw instanceof Date ? raw : new Date(raw)
if (Number.isNaN(d.getTime())) return { error: 'expected a date' }
return { value: d.toISOString() }
}
default:
return { error: `unsupported type "${type}"` }
}
}
/**
* Validate a condition tree against a trigger declaration.
*
* Returns `{ ok: true, conditions }` with a NEW normalised tree — literals
* coerced, unknown keys dropped — or `{ ok: false, errors }` listing every
* problem rather than the first, the posture `validatePayload` takes and for the
* same reason: an operator fixing one clause at a time is an operator making six
* round trips through a form.
*
* `null` and `undefined` are valid and mean "no conditions" — a rule that fires
* on every occurrence of its trigger, which is the common case.
*/
function validate(declaration, raw) {
const errors = []
const variables = new Map((declaration?.variables || []).map((v) => [v.name, v]))
function walk(node, depth, path) {
if (depth > MAX_DEPTH) {
errors.push(`${path}: nested deeper than ${MAX_DEPTH}`)
return null
}
if (!isPlainObject(node)) {
errors.push(`${path}: expected an object`)
return null
}
if (BOOLEAN_OPS.includes(node.op)) {
// `not` takes exactly one node; `and`/`or` take a list. Both are written
// as `nodes` so a client walks one shape.
const raws = Array.isArray(node.nodes) ? node.nodes : []
if (!raws.length) {
errors.push(`${path}: "${node.op}" has no nodes`)
return null
}
if (node.op === 'not' && raws.length !== 1) {
errors.push(`${path}: "not" takes exactly one node`)
return null
}
const nodes = raws.map((child, i) => walk(child, depth + 1, `${path}.nodes[${i}]`)).filter(Boolean)
return nodes.length === raws.length ? { op: node.op, nodes } : null
}
if (node.op !== undefined) {
errors.push(`${path}: unknown operator "${node.op}"`)
return null
}
// A leaf: { variable, cmp, value }.
const variable = variables.get(node.variable)
if (!variable) {
errors.push(`${path}: "${node.variable}" is not a variable of "${declaration?.id}"`)
return null
}
const operator = OPERATORS[node.cmp]
if (!operator) {
errors.push(`${path}: unknown comparison "${node.cmp}"`)
return null
}
if (!operator.types.includes(variable.type)) {
errors.push(`${path}: "${node.cmp}" cannot be applied to a ${variable.type}`)
return null
}
if (operator.arity === 0) return { variable: variable.name, cmp: node.cmp }
if (operator.arity === 'list') {
if (!Array.isArray(node.value) || !node.value.length) {
errors.push(`${path}: "${node.cmp}" needs a non-empty list`)
return null
}
if (node.value.length > MAX_LIST) {
errors.push(`${path}: "${node.cmp}" list is longer than ${MAX_LIST}`)
return null
}
const value = []
let bad = false
node.value.forEach((item, i) => {
const checked = checkLiteral(variable.type, item)
if (checked.error) {
errors.push(`${path}.value[${i}]: ${checked.error}`)
bad = true
} else value.push(checked.value)
})
return bad ? null : { variable: variable.name, cmp: node.cmp, value }
}
const checked = checkLiteral(variable.type, node.value)
if (checked.error) {
errors.push(`${path}: ${checked.error}`)
return null
}
return { variable: variable.name, cmp: node.cmp, value: checked.value }
}
if (raw === null || raw === undefined) return { ok: true, conditions: null }
const conditions = walk(raw, 0, 'conditions')
return errors.length ? { ok: false, errors } : { ok: true, conditions }
}
/** Compare one already-normalised leaf against a payload. */
function evaluateLeaf(leaf, data) {
const present = Object.prototype.hasOwnProperty.call(data, leaf.variable)
const actual = data[leaf.variable]
if (leaf.cmp === 'present') return present
if (leaf.cmp === 'absent') return !present
// Every other comparison against an absent variable is FALSE, never true.
// `ne` is the one that tempts otherwise — "not equal to X" reads as satisfied
// by nothing at all — and treating it as true would make an optional variable's
// absence fire the rule.
if (!present) return false
switch (leaf.cmp) {
case 'eq': return actual === leaf.value
case 'ne': return actual !== leaf.value
case 'in': return leaf.value.includes(actual)
case 'nin': return !leaf.value.includes(actual)
case 'gt': return actual > leaf.value
case 'gte': return actual >= leaf.value
case 'lt': return actual < leaf.value
case 'lte': return actual <= leaf.value
case 'contains': return typeof actual === 'string' && actual.includes(leaf.value)
case 'startsWith': return typeof actual === 'string' && actual.startsWith(leaf.value)
default: return false
}
}
/**
* Does this event's payload satisfy the rule's conditions?
*
* `null` conditions are satisfied — a rule with no conditions fires on every
* occurrence. A tree this evaluator does not recognise answers **false**, which
* is the fail-closed direction: a stored condition that no longer parses (a rule
* saved against an older trigger version, say) must stop the mail rather than
* become "no conditions" and mail everyone.
*/
function evaluate(conditions, data = {}) {
if (conditions === null || conditions === undefined) return true
if (!isPlainObject(conditions)) return false
if (conditions.op === 'and') return (conditions.nodes || []).every((n) => evaluate(n, data))
if (conditions.op === 'or') return (conditions.nodes || []).some((n) => evaluate(n, data))
if (conditions.op === 'not') return !evaluate((conditions.nodes || [])[0], data)
if (conditions.op !== undefined) return false
return evaluateLeaf(conditions, data)
}
/**
* The operator vocabulary a rule editor renders, with the variable types each
* one applies to. Served with the rule surface in Phase 4b rather than hardcoded
* in the client, on the same argument the ceiling vocabulary is served with the
* trigger catalog: a second copy of a rule is a copy that drifts.
*/
const vocabulary = () =>
Object.entries(OPERATORS).map(([cmp, o]) => ({ cmp, label: o.label, types: o.types, arity: o.arity }))
/** Convenience for a caller holding only a trigger id. */
const validateFor = (triggerId, raw) => validate(registries.eventTrigger(triggerId), raw)
// `checkLiteral` is exported for the event system's step-param validator
// (EVENTS.md §F, Phase 1), which checks an authored param value against an
// action's declared param type — the same six types over the same coercion. A
// second copy of this switch would be a second answer to "is this a datetime",
// and the two would drift on the first zone-suffixed string somebody typed.
module.exports = {
validate,
validateFor,
evaluate,
vocabulary,
checkLiteral,
OPERATORS,
MAX_LIST,
MAX_DEPTH,
}