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>
266 lines
11 KiB
JavaScript
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,
|
|
}
|