feat(events): a param that holds several values (MODULE_API 1.14.0)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 47s
PR Checks / client-build (pull_request) Successful in 48s
PR Checks / server-tests (pull_request) Successful in 13m55s

Rust PLAN_REDESIGNS §12.3 (D344): an event action param may declare
`multiple: true`, with an optional `max` (1-100, 20 when left out), on a
`string` or `int` param. The permission reward steps take several permissions
in one step through it.

- registries: the flag and `max` are checked at registration and served in the
  catalog; refused on other types, on a duration, on a template's inputs, and
  with a list as the example. A param that does not say it reads back exactly
  as declared, without the keys.
- spec: the value is a JSON array, each item the type's literal, duplicates
  dropped in order, at most `max`; a bare value is a list of one; `required`
  means at least one; an empty item is refused.
- authorize: a multiple param's example is priced as a list of one, since
  cost() is handed the array.
- editor: a multi-select. Chips above a grouped, filterable checklist for a
  source that answered; the typeahead for a searchable source; a tag box
  otherwise. Ticking past max is refused in words. A new step holds the
  example as a list of one.
- run console: params read as `name: value`, a list joined by commas.
- MODULE_API 1.13.0 -> 1.14.0 on both halves; engagement-triggers.json and the
  catalog's OpenAPI note regenerated.

Additive only: Module-uo (`^1.10.0`) declares no multiple param.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-12 15:40:30 -05:00
parent ca7ec53b14
commit 743f1bc8b2
14 changed files with 616 additions and 14 deletions

View File

@@ -144,7 +144,9 @@ function priceOf(action, params) {
function dimensionsOf(action) {
const params = {}
for (const p of action?.params || []) {
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
// A `multiple` param's example is one value and `cost()` is handed a list
// (1.14.0), so it is priced as a list of one.
if (p.example !== undefined && p.example !== null) params[p.name] = p.multiple ? [p.example] : p.example
}
const priced = priceOf(action, params)
return priced ? Object.keys(priced).sort() : []

View File

@@ -323,6 +323,37 @@ function validateAdvance(raw, path, errors) {
return { on: triggerId, where: checked.conditions, count, dormant: false }
}
/**
* A `multiple` param's value as the list it is (MODULE_API 1.14.0, Rust
* PLAN_REDESIGNS §12.3).
*
* `{ value: [...] }` or `{ error }`. **A bare value is a list of one**, so a
* spec written by hand or over the API before its author knew the param took
* several does not trip on it. Absent, `null`, `''` and `[]` are all the empty
* list, which the caller reads as unset — `required` means at least one.
*
* Each element is the type's literal, checked by the same `checkLiteral` a
* single value is; an empty string inside the list is refused rather than kept,
* because a blank chip is nothing anyone picked. Duplicates are dropped keeping
* the first, so the order the author picked in is the order the module reads.
*/
function checkList(p, raw) {
if (raw === undefined || raw === null || raw === '') return { value: [] }
const items = Array.isArray(raw) ? raw : [raw]
const out = []
const seen = new Set()
for (let i = 0; i < items.length; i++) {
if (items[i] === '') return { error: `item ${i + 1} is empty` }
const checked = checkLiteral(p.type, items[i])
if (checked.error) return { error: `item ${i + 1} ${checked.error}` }
if (seen.has(checked.value)) continue
seen.add(checked.value)
out.push(checked.value)
}
if (out.length > p.max) return { error: `holds ${out.length} values; the most is ${p.max}` }
return { value: out }
}
/**
* Check one authored param object against an action's declared params.
*
@@ -349,6 +380,14 @@ function checkParams(declaration, raw, path) {
for (const p of declaration.params || []) {
const value = given[p.name]
if (p.multiple) {
const listed = checkList(p, value)
if (listed.error) errors.push(`${path}.params: "${p.name}" ${listed.error}`)
else if (!listed.value.length) {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
} else params[p.name] = listed.value
continue
}
if (value === undefined || value === null || value === '') {
if (p.required) errors.push(`${path}.params: "${p.name}" is required`)
continue

View File

@@ -1030,11 +1030,23 @@ const MAX_BUDGET_MS = 3_600_000
// vocabulary of one is a vocabulary that can grow without a version.
const PARAM_DURATIONS = ['minutes']
// A param that holds several values (MODULE_API 1.14.0; Rust PLAN_REDESIGNS
// §12.3, D344). Two types, because the one step that needed it picks strings
// from a source and an int list is the same check one type over; a list of
// booleans, dates or links is a shape nothing has asked for, and every control
// the editor draws for a list is a picker or a tag box.
const MULTIPLE_PARAM_TYPES = ['string', 'int']
// `max` when the declaration leaves it out, and the most it may say. The ceiling
// is a bound on one step's params, not a number anyone should reach: a list of a
// hundred is a step nobody can read back.
const DEFAULT_MULTIPLE_MAX = 20
const MAX_MULTIPLE_MAX = 100
// `call` names the registration in the error, because a template's inputs are
// checked by this same function (they ARE params: one control each, with a
// name, a type, an example and maybe a source).
function checkActionParam(actionId, entry, seen, call = 'registerEventActions') {
const { name, type, required, example, description, source, duration } = entry || {}
const { name, type, required, example, description, source, duration, multiple, max } = entry || {}
const where = `${call}: ${actionId}`
if (!PARAM_NAME.test(name || '')) throw new Error(`${where}: bad param name "${name}"`)
if (seen.has(name)) throw new Error(`${where}: param "${name}" declared twice`)
@@ -1072,6 +1084,35 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
throw new Error(`${where}: param "${name}" is a duration and must be int or float, not ${type}`)
}
}
// **`multiple` makes the value a list** (1.14.0, §12.3). The example stays ONE
// value — it is the placeholder of the box a value is typed into — and the
// value a step stores is a JSON array of them (`events/spec.js`).
//
// Refused on a duration, because a list of lengths of time is not a hold the
// editor can keep a phase open for, and on a template's inputs, because
// `build()` is a module's own code reading `inputs.x` as one value and this
// version does not change what it is handed.
if (multiple !== undefined && multiple !== null && multiple !== false) {
if (multiple !== true) throw new Error(`${where}: param "${name}" multiple must be true or left out`)
if (call === 'registerEventTemplates') {
throw new Error(`${where}: input "${name}" cannot be multiple; a template's inputs are single values`)
}
if (!MULTIPLE_PARAM_TYPES.includes(type)) {
throw new Error(`${where}: param "${name}" is multiple and must be ${MULTIPLE_PARAM_TYPES.join(' or ')}, not ${type}`)
}
if (duration !== undefined && duration !== null) {
throw new Error(`${where}: param "${name}" cannot be both multiple and a duration`)
}
if (Array.isArray(example)) {
throw new Error(`${where}: param "${name}" needs ONE example, not a list (it is the placeholder of one value)`)
}
}
if (max !== undefined && max !== null) {
if (multiple !== true) throw new Error(`${where}: param "${name}" has a max but is not multiple`)
if (!Number.isInteger(max) || max < 1 || max > MAX_MULTIPLE_MAX) {
throw new Error(`${where}: param "${name}" max must be 1..${MAX_MULTIPLE_MAX}, got "${max}"`)
}
}
return {
name,
type,
@@ -1080,6 +1121,9 @@ function checkActionParam(actionId, entry, seen, call = 'registerEventActions')
source: source === undefined ? null : source,
description: description || '',
...(duration ? { duration } : {}),
// Absent rather than `false` on every other param, as `duration` is, so a
// module that never says it reads back exactly the declaration it wrote.
...(multiple === true ? { multiple: true, max: max ?? DEFAULT_MULTIPLE_MAX } : {}),
}
}
@@ -2164,6 +2208,9 @@ module.exports = {
allEventTemplates,
eventTemplate,
PARAM_DURATIONS,
MULTIPLE_PARAM_TYPES,
DEFAULT_MULTIPLE_MAX,
MAX_MULTIPLE_MAX,
resolveOptionSource,
allEngagementSeeds,
engagementSeedsFor,

View File

@@ -9,6 +9,16 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.14.0 — an action param may hold several values: `multiple: true`, with an
// optional `max` (1–100, 20 if left out), on a `string` or `int` param (Rust
// PLAN_REDESIGNS §12.3, D344; docs/website/EVENTS.md §F). The step stores a JSON
// array; a bare value is read as a list of one; `required` means at least one;
// `perform()` and `cost()` are handed the array. Refused on a duration and on a
// template's inputs. The editor draws it as a multi-select.
// One optional flag, so minor. Module-uo declares `^1.10.0` and uses none of it:
// a param that does not say `multiple` is checked, stored and drawn exactly as it
// was, and the declaration the registry hands back does not grow the key.
//
// 1.13.0 — the event step editor (Rust PLAN_REDESIGNS §2; docs/website/EVENTS.md
// §F, §I). Four additions, each optional:
// • `api.registerEventTemplates([{ id, label, description, inputs, build }])` —
@@ -188,6 +198,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.13.0'
const MODULE_API_VERSION = '1.14.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -51,7 +51,7 @@ eventsRouter.get(
// #swagger.summary = 'List every registered event action, with its param schema, risk class and reversibility'
// #swagger.description = 'Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against. Phase 5 added `triggers` and `operators`: the trigger catalog a module already ships IS the catalog of things a phase can advance on, and it is served here rather than borrowed from /admin/engagement/triggers because that route is admin-only while an event definition is authored by admin AND editor. Each trigger is reduced to its id, label and declared variables — a trigger's audience and ceiling are about who gets mailed, which is not this screen's question. Phase 7 added `budgets`, `leases` and `optionSources`: the other three registrations of the module contract, served beside the actions because the step editor needs all four to draw ONE step — the action says what params it takes, a param source names a dropdown, and a cap box is a budget label and unit. A source resolves its VALUES on a request of its own (/options/:sourceId), because a source can be slow or down and must not take the catalog with it.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[200] = { description: 'The registered actions and triggers, and the vocabularies over them', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", description: "Each action with its declared params. MODULE_API 1.14.0: a param may carry multiple: true and max (1 to 100, 20 when left out), and its value is then a JSON array of at most max values.", items: { type: "object", additionalProperties: true } }, triggers: { type: "array", items: { type: "object", additionalProperties: true } }, operators: { type: "array", items: { type: "object", additionalProperties: true } }, risks: { type: "array", items: { type: "string" } }, reversible: { type: "array", items: { type: "string" } }, paramTypes: { type: "array", items: { type: "string" } }, onFailure: { type: "array", items: { type: "string" } }, onFailureByRisk: { type: "object", additionalProperties: true }, scheduleKinds: { type: "array", items: { type: "string" } }, advanceKinds: { type: "array", items: { type: "string" } }, limits: { type: "object", additionalProperties: true }, templates: { type: "array", description: "Event templates (MODULE_API 1.13.0): id, label, description, owner and inputs (each shaped like an action param). build() is never served.", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
controller.catalog,
)