feat(events): open the event contract to modules (Phase 7)
MODULE_API 1.10.0. Four names forwarded on the module-facing `api` -- registerEventActions, registerEventBudgets, registerEventLeases and registerEventOptionSources -- one new route, and one rule made real: a `cost()` naming a dimension no module declared is refused. Only one of the four is new machinery. The action registry has staged core's three actions on every boot since Phase 1; what it never had was a way in, because loader.js builds its own `api` facade and had no method that delegated to it. So the registry a module now reaches is one that has been exercised on every boot for six phases. Four decisions, settled 2026-09-03, all as recommended: - Option sources are their own registration, modelled on registerAudiences, because a catalog has more than one consumer. - An undeclared dimension is refused -- at save, at the dry run and at dispatch -- with its own code, because the fix is a module's declaration and not a deployment's cap. - A lease is declared here and acquired by nothing; the ledger is Phase 8. - Core registers core.options.legs, so an announce leg is a dropdown rather than the free-text box whose typo Phase 6's walk caught mid-run. Proved with a throwaway module through the real loader, not with module-uo: eventModuleContract.test.js writes a module to a real directory and lets the loader scan it, covering all five envelope failure shapes, verify: true, the four id spaces and dormancy on uninstall. The live walk found the one defect nothing else could: the option-source loader wrote its "already asked?" guard inside a setState updater and read it on the next line, so the request was never made and the field sat on "Reading the list..." for ever. It is a useRef now. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
@@ -370,6 +370,41 @@ function buildApi(record) {
|
||||
once('registerEngagementSeeds')
|
||||
record.staged.registerEngagementSeeds(seeds)
|
||||
},
|
||||
// The event contract (API 1.10.0, EVENTS.md §F). **This is the seam Phase 1
|
||||
// built and did not open**: `registerEventActions` has staged core's three
|
||||
// actions on every boot since then and no module could reach it, because
|
||||
// this facade had no method that delegated. The four lines below are what
|
||||
// Phase 7 ships — core has been going through the same door for six phases,
|
||||
// so the registry a module now reaches is one that has been exercised on
|
||||
// every boot rather than one whose first registrant is a stranger.
|
||||
//
|
||||
// `once` on all four, for the reason every batch registration above takes
|
||||
// it: a batch is a module's complete statement about what it declares, and a
|
||||
// second call is a module changing its mind halfway through `register()`
|
||||
// rather than adding to it.
|
||||
//
|
||||
// The four id spaces are separate and the loader does not police that —
|
||||
// `registries.apply()` does, per space. An action names a VERB, a budget
|
||||
// names a RESOURCE, a lease names a VALUE and an option source names a
|
||||
// CATALOG, so `uo.creatures` may legitimately appear in more than one of
|
||||
// them and reading that as a collision would forbid the most natural set of
|
||||
// names a module will ever write.
|
||||
registerEventActions(actions) {
|
||||
once('registerEventActions')
|
||||
record.staged.registerEventActions(actions)
|
||||
},
|
||||
registerEventBudgets(budgets) {
|
||||
once('registerEventBudgets')
|
||||
record.staged.registerEventBudgets(budgets)
|
||||
},
|
||||
registerEventLeases(leases) {
|
||||
once('registerEventLeases')
|
||||
record.staged.registerEventLeases(leases)
|
||||
},
|
||||
registerEventOptionSources(sources) {
|
||||
once('registerEventOptionSources')
|
||||
record.staged.registerEventOptionSources(sources)
|
||||
},
|
||||
// The two lifecycle hooks (§2.5). Registered here, dispatched from
|
||||
// lifecycle.js — this file runs with no database and the hooks run with one.
|
||||
// Both are optional: a module with no warm-up and nothing to close simply
|
||||
|
||||
@@ -138,6 +138,44 @@ const audiences = new Map()
|
||||
// order is what the admin catalog renders in.
|
||||
const eventActions = new Map()
|
||||
|
||||
// budget id → { owner, id, label, unit, description } (EVENTS.md §F, Phase 7).
|
||||
//
|
||||
// A dimension of consumption — "creatures spawned", "gate uptime" — declared so
|
||||
// that the switchboard's cap editor has a NAME and a UNIT to put beside a number.
|
||||
// Phase 6 discovered these by pricing an action's declared `example` values,
|
||||
// which was a stand-in that could name a dimension and never label it.
|
||||
//
|
||||
// **Its own id space**, like `eventActions` above, and §F says why in one line:
|
||||
// an action names a VERB and a budget names a RESOURCE. Nothing cross-checks the
|
||||
// two maps, and nothing should.
|
||||
//
|
||||
// Data only. There is no function on a budget and nothing here is ever called —
|
||||
// the module says a dimension exists and what to call it, `cost()` says how much
|
||||
// of it a step spends, and core owns every piece of arithmetic in between.
|
||||
const eventBudgets = new Map()
|
||||
|
||||
// lease id → { owner, id, label, type, min, max, maxDurationMs, description,
|
||||
// read, apply, restore } (§F "Leases: one more declaration", Phase 7).
|
||||
//
|
||||
// **Phase 7 registers a lease and nothing acquires one.** Core owns the duration
|
||||
// and the conflict check, the module owns reading the current value and writing a
|
||||
// new one — and both halves of that live in the resource ledger, which is Phase
|
||||
// 8's. What is here is the declaration, its validation and its catalog entry, so
|
||||
// that the module contract is one version rather than two.
|
||||
const eventLeases = new Map()
|
||||
|
||||
// source id → { owner, id, label, description, resolve } (§F "Param option
|
||||
// sources", Phase 7).
|
||||
//
|
||||
// What turns an authoring field from a text box into a dropdown of real
|
||||
// landmarks. Modelled on `audiences` rather than on anything else here, because
|
||||
// it is the same shape of thing: an id, a label, and a `resolve()` core calls and
|
||||
// waits for. What differs is the meaning of a refusal — an audience that refuses
|
||||
// mails nobody, while a source that refuses degrades its field to free text with
|
||||
// a warning, because refusing to let an operator type a value they already know
|
||||
// is worse than the typo the dropdown existed to prevent.
|
||||
const eventOptionSources = new Map()
|
||||
|
||||
// owner → { templates: [...], ruleGroups: [...] } (ENGAGEMENT.md Phase 11b,
|
||||
// decision 7). What a module ships as CONTENT rather than as contract: the
|
||||
// bodies its triggers render through, and the rules an operator switches on.
|
||||
@@ -181,6 +219,13 @@ const AUDIENCE_ID = EVENT_ID
|
||||
// spaces"). One grammar, three namespaces — the constant is what makes the
|
||||
// namespace visible at every use site.
|
||||
const ACTION_ID = EVENT_ID
|
||||
// And three more id spaces on the same grammar, arriving with the module
|
||||
// contract in Phase 7. Three constants rather than three uses of ACTION_ID, for
|
||||
// the reason AUDIENCE_ID gets its own: the constant is what makes the namespace
|
||||
// visible at the use site, so a future divergence has one place to happen.
|
||||
const BUDGET_ID = EVENT_ID
|
||||
const LEASE_ID = EVENT_ID
|
||||
const OPTION_SOURCE_ID = EVENT_ID
|
||||
|
||||
// A module's claim must carry its id. Core's ids are its own namespace, and the
|
||||
// grandfathered names are the ones that predate all of this.
|
||||
@@ -412,6 +457,86 @@ const eventAction = (id) => eventActions.get(id) || null
|
||||
*/
|
||||
const isEventAction = (id) => eventActions.has(id)
|
||||
|
||||
// ── Event budgets, leases and option sources (§F, Phase 7) ─────────────
|
||||
|
||||
/** Every declared budget dimension, in registration order. */
|
||||
const allEventBudgets = () => [...eventBudgets.values()]
|
||||
|
||||
/** One dimension's declaration, or null. The label-and-unit lookup. */
|
||||
const eventBudget = (id) => eventBudgets.get(id) || null
|
||||
|
||||
/**
|
||||
* Does anyone declare this dimension right now?
|
||||
*
|
||||
* The fail-closed question (org lead, 2026-09-03): a `cost()` naming a dimension
|
||||
* nobody registered is REFUSED — at save, at the dry run and at dispatch. §F's
|
||||
* *"a module cannot spend a budget it did not declare"* is only true if something
|
||||
* asks, and this is what asks.
|
||||
*/
|
||||
const isEventBudget = (id) => eventBudgets.has(id)
|
||||
|
||||
/**
|
||||
* Every lease declaration WITHOUT its callables — what the catalog serves.
|
||||
*
|
||||
* Stripped for the reason `perform` is stripped from an action: this object
|
||||
* leaves the process, and the browser's whole relationship with a lease is naming
|
||||
* one by id.
|
||||
*/
|
||||
const allEventLeases = () =>
|
||||
[...eventLeases.values()].map(({ read, apply: applyValue, restore, ...rest }) => rest)
|
||||
|
||||
/** One lease, callables included. Phase 8's lookup; nothing calls it yet. */
|
||||
const eventLease = (id) => eventLeases.get(id) || null
|
||||
|
||||
/** Every option source WITHOUT its resolver — the authoring form's list. */
|
||||
const allEventOptionSources = () =>
|
||||
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
|
||||
|
||||
/**
|
||||
* Resolve one option source, or say why not. Never throws.
|
||||
*
|
||||
* **A refusal is not an error here, and that is the design.** §F: a source that
|
||||
* cannot answer degrades its field to free text with a visible warning rather
|
||||
* than blocking the form. So every failure shape — no such source, a throw, a
|
||||
* rejected promise, a non-array — comes back as `{ ok: false, reason }` and the
|
||||
* caller renders a text box. The alternative is an authoring screen that a
|
||||
* module's outage can make unusable, for a field whose value the operator very
|
||||
* often already knows.
|
||||
*
|
||||
* The options are normalised rather than trusted. This array is rendered into a
|
||||
* `<select>`, so an entry with no `value` is dropped instead of becoming an
|
||||
* option that submits the string "undefined", and `label` falls back to the value
|
||||
* rather than to nothing — a dropdown of blank rows is a worse field than the
|
||||
* text box it replaced.
|
||||
*/
|
||||
async function resolveOptionSource(id) {
|
||||
const entry = eventOptionSources.get(id)
|
||||
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
|
||||
let raw
|
||||
try {
|
||||
raw = await entry.resolve()
|
||||
} catch (err) {
|
||||
log.error('option source resolver failed', {
|
||||
source: id,
|
||||
owner: entry.owner,
|
||||
message: err.message,
|
||||
})
|
||||
return { ok: false, reason: `"${entry.label}" could not be read` }
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return { ok: false, reason: `"${entry.label}" answered with no option list` }
|
||||
}
|
||||
const options = []
|
||||
for (const o of raw) {
|
||||
if (!o || typeof o !== 'object') continue
|
||||
if (o.value === undefined || o.value === null || o.value === '') continue
|
||||
const option = { value: String(o.value), label: String(o.label ?? o.value) }
|
||||
if (o.group) option.group = String(o.group)
|
||||
options.push(option)
|
||||
}
|
||||
return { ok: true, id, label: entry.label, owner: entry.owner, options }
|
||||
}
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
//
|
||||
// Split from the collision checks below on the same line PR 3 drew through
|
||||
@@ -954,6 +1079,140 @@ function checkEventActionShape(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
// A lease's value type. Closed, like `risk` and `reversible`, and for the same
|
||||
// reason: core validates an operator's input against it at authoring time, so a
|
||||
// type core does not know is a lease core cannot bound.
|
||||
const LEASE_TYPES = ['int', 'float', 'bool', 'string']
|
||||
|
||||
// Thirty days. A lease is a promise the game side keeps WITHOUT being asked again
|
||||
// (§F), so its ceiling is the longest outage a restore may have to survive rather
|
||||
// than a scheduling convenience. Past that, "temporary" has stopped meaning
|
||||
// anything an operator can hold in their head.
|
||||
const MAX_LEASE_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* `registerEventBudgets([{ id, label, unit, description }])`.
|
||||
*
|
||||
* A dimension of consumption core can bound. Data only — the module says a
|
||||
* dimension exists and what to call it, `cost()` says how much of it a step
|
||||
* spends, and core owns the arithmetic in between (§F, *"cost is declared by the
|
||||
* module and enforced by core"*).
|
||||
*
|
||||
* **`unit` is required, and its vocabulary is open.** Required because a bare
|
||||
* number on a cap box is ambiguous in exactly the case that matters — 30 of
|
||||
* what? — and open because core never interprets it. It is a display word beside
|
||||
* a number, and closing the set would make "kilometres" a MODULE_API bump for a
|
||||
* noun core does not read.
|
||||
*/
|
||||
function checkEventBudgetShape(entry) {
|
||||
const b = entry || {}
|
||||
if (!BUDGET_ID.test(b.id || '')) {
|
||||
throw new Error(`registerEventBudgets: bad budget id "${b.id}"`)
|
||||
}
|
||||
if (!b.label) throw new Error(`registerEventBudgets: budget "${b.id}" has no label`)
|
||||
if (!b.unit) {
|
||||
throw new Error(`registerEventBudgets: budget "${b.id}" has no unit (it is rendered beside the cap)`)
|
||||
}
|
||||
return { id: b.id, label: b.label, unit: String(b.unit), description: b.description || '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventLeases([{ id, label, type, min, max, maxDurationMs, read, apply, restore }])`.
|
||||
*
|
||||
* A value a run may borrow and must give back. Core owns the duration and the
|
||||
* conflict check; the module owns reading the current value and writing a new one
|
||||
* — the split §F draws, and the reason all three callables are required rather
|
||||
* than one of them.
|
||||
*
|
||||
* **`restore` is required even though `read` could stand in for it.** They answer
|
||||
* different questions: `read` is *"what is it now"*, `restore` is *"put this back,
|
||||
* and tell me if someone else has moved it"* — the drift check, which is the one
|
||||
* thing a module must not be allowed to skip. A lease whose restore writes blindly
|
||||
* is a lease that silently reverts an operator's manual fix.
|
||||
*
|
||||
* **Nothing acquires a lease in Phase 7.** This registers, validates and serves
|
||||
* one; the ledger that holds it, the deadline that goes down the wire and the
|
||||
* drift answer are Phase 8's. Declaring it now is what keeps the module contract
|
||||
* one version rather than two.
|
||||
*/
|
||||
function checkEventLeaseShape(entry) {
|
||||
const l = entry || {}
|
||||
if (!LEASE_ID.test(l.id || '')) throw new Error(`registerEventLeases: bad lease id "${l.id}"`)
|
||||
if (!l.label) throw new Error(`registerEventLeases: lease "${l.id}" has no label`)
|
||||
if (!LEASE_TYPES.includes(l.type)) {
|
||||
throw new Error(`registerEventLeases: ${l.id} needs a type, one of ${LEASE_TYPES.join(', ')}`)
|
||||
}
|
||||
|
||||
// Only the numeric types carry a range, and for those it is REQUIRED. A lease
|
||||
// on a rate multiplier with no bounds is an operator one keystroke away from
|
||||
// setting a shard's skill gain to 5000, which is the class of accident the
|
||||
// whole cap machinery exists to make impossible — and unlike a cap, a bad lease
|
||||
// value is in force the moment it is applied.
|
||||
let min = null
|
||||
let max = null
|
||||
if (l.type === 'int' || l.type === 'float') {
|
||||
min = Number(l.min)
|
||||
max = Number(l.max)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
throw new Error(`registerEventLeases: ${l.id} is ${l.type} and needs a numeric min and max`)
|
||||
}
|
||||
if (l.type === 'int' && (!Number.isInteger(min) || !Number.isInteger(max))) {
|
||||
throw new Error(`registerEventLeases: ${l.id} is int and needs whole-number min and max`)
|
||||
}
|
||||
if (min > max) throw new Error(`registerEventLeases: ${l.id} has min ${min} above max ${max}`)
|
||||
}
|
||||
|
||||
const maxDurationMs = l.maxDurationMs
|
||||
if (!Number.isInteger(maxDurationMs) || maxDurationMs <= 0 || maxDurationMs > MAX_LEASE_MS) {
|
||||
throw new Error(
|
||||
`registerEventLeases: ${l.id} maxDurationMs must be 1..${MAX_LEASE_MS} ms, got "${l.maxDurationMs}"`,
|
||||
)
|
||||
}
|
||||
for (const fn of ['read', 'apply', 'restore']) {
|
||||
if (typeof l[fn] !== 'function') throw new Error(`registerEventLeases: ${l.id} has no ${fn}()`)
|
||||
}
|
||||
|
||||
return {
|
||||
id: l.id,
|
||||
label: l.label,
|
||||
description: l.description || '',
|
||||
type: l.type,
|
||||
min,
|
||||
max,
|
||||
maxDurationMs,
|
||||
read: l.read,
|
||||
apply: l.apply,
|
||||
restore: l.restore,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `registerEventOptionSources([{ id, label, description, resolve }])`.
|
||||
*
|
||||
* The values behind a param's `source` (§F, *Param option sources*). Its own
|
||||
* registration rather than a field on the action that names it, because a catalog
|
||||
* has more than one consumer: `uo.options.items` is the allowlist for granting an
|
||||
* item and for taking one back, and two actions declaring it separately would be
|
||||
* two allowlists that can disagree.
|
||||
*
|
||||
* `resolve()` answers `[{ value, label, group? }]`. It may be async, it may talk
|
||||
* to a sidecar, and it may fail — the failure is handled at the call
|
||||
* (`resolveOptionSource`) rather than here, because the answer to a source that
|
||||
* cannot answer is a text box, not a broken form.
|
||||
*/
|
||||
function checkEventOptionSourceShape(entry) {
|
||||
const s = entry || {}
|
||||
if (!OPTION_SOURCE_ID.test(s.id || '')) {
|
||||
throw new Error(`registerEventOptionSources: bad option source id "${s.id}"`)
|
||||
}
|
||||
if (!s.label) throw new Error(`registerEventOptionSources: source "${s.id}" has no label`)
|
||||
if (typeof s.resolve !== 'function') {
|
||||
throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`)
|
||||
}
|
||||
return { id: s.id, label: s.label, description: s.description || '', resolve: s.resolve }
|
||||
}
|
||||
|
||||
|
||||
// ── Engagement seeds (Phase 11b, decision 7) ───────────────────────────────
|
||||
//
|
||||
// **Two mechanisms, and the asymmetry between them is the whole design.**
|
||||
@@ -1194,6 +1453,9 @@ function stage(owner) {
|
||||
audiences: [],
|
||||
eventActions: [],
|
||||
engagementSeeds: [],
|
||||
eventBudgets: [],
|
||||
eventLeases: [],
|
||||
eventOptionSources: [],
|
||||
}
|
||||
return {
|
||||
staged,
|
||||
@@ -1235,6 +1497,23 @@ function stage(owner) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventActions: expected an array')
|
||||
for (const e of entries) staged.eventActions.push(checkEventActionShape(e))
|
||||
},
|
||||
// The three that arrive WITH the module-facing seam (Phase 7). Unlike
|
||||
// `registerEventActions` above, these have never had a core-only period:
|
||||
// `loader.js` forwards all four from the boot this lands on, and core
|
||||
// registers through them on the same boot, which is the posture
|
||||
// `registerCore()` has taken since the module system's Phase 3.
|
||||
registerEventBudgets(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventBudgets: expected an array')
|
||||
for (const e of entries) staged.eventBudgets.push(checkEventBudgetShape(e))
|
||||
},
|
||||
registerEventLeases(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventLeases: expected an array')
|
||||
for (const e of entries) staged.eventLeases.push(checkEventLeaseShape(e))
|
||||
},
|
||||
registerEventOptionSources(entries) {
|
||||
if (!Array.isArray(entries)) throw new Error('registerEventOptionSources: expected an array')
|
||||
for (const e of entries) staged.eventOptionSources.push(checkEventOptionSourceShape(e))
|
||||
},
|
||||
registerEngagementSeeds(entry) {
|
||||
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
|
||||
},
|
||||
@@ -1261,6 +1540,9 @@ function apply({
|
||||
triggers: newTriggers = [],
|
||||
audiences: newAudiences = [],
|
||||
eventActions: newEventActions = [],
|
||||
eventBudgets: newEventBudgets = [],
|
||||
eventLeases: newEventLeases = [],
|
||||
eventOptionSources: newEventOptionSources = [],
|
||||
engagementSeeds: newSeeds = [],
|
||||
}) {
|
||||
// ── validate ──
|
||||
@@ -1334,6 +1616,48 @@ function apply({
|
||||
seenActions.add(a.id)
|
||||
}
|
||||
|
||||
// Budgets, leases and option sources: three more id spaces, checked against
|
||||
// their own maps and against nothing else, for the reason the actions loop
|
||||
// above gives. No legacy allowlist on any of the three — nothing predates them,
|
||||
// so the prefix rule has no exceptions and should never grow one.
|
||||
//
|
||||
// The one cross-facet check that would be wrong here is budget-against-action:
|
||||
// §F puts them in separate id spaces deliberately, and `uo.creatures` as a
|
||||
// dimension beside `uo.creature.spawn` as a verb is the most natural pair of
|
||||
// names a module will ever write.
|
||||
const seenBudgets = new Set()
|
||||
for (const b of newEventBudgets) {
|
||||
const held = eventBudgets.get(b.id)
|
||||
if (held) throw new Error(`event budget "${b.id}" is already registered by "${held.owner}"`)
|
||||
if (seenBudgets.has(b.id)) throw new Error(`event budget "${b.id}" registered twice`)
|
||||
if (!namespaced(owner, b.id, {})) {
|
||||
throw new Error(`event budget "${b.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenBudgets.add(b.id)
|
||||
}
|
||||
|
||||
const seenLeases = new Set()
|
||||
for (const l of newEventLeases) {
|
||||
const held = eventLeases.get(l.id)
|
||||
if (held) throw new Error(`event lease "${l.id}" is already registered by "${held.owner}"`)
|
||||
if (seenLeases.has(l.id)) throw new Error(`event lease "${l.id}" registered twice`)
|
||||
if (!namespaced(owner, l.id, {})) {
|
||||
throw new Error(`event lease "${l.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenLeases.add(l.id)
|
||||
}
|
||||
|
||||
const seenSources = new Set()
|
||||
for (const s of newEventOptionSources) {
|
||||
const held = eventOptionSources.get(s.id)
|
||||
if (held) throw new Error(`option source "${s.id}" is already registered by "${held.owner}"`)
|
||||
if (seenSources.has(s.id)) throw new Error(`option source "${s.id}" registered twice`)
|
||||
if (!namespaced(owner, s.id, {})) {
|
||||
throw new Error(`option source "${s.id}" is not namespaced "${owner}."`)
|
||||
}
|
||||
seenSources.add(s.id)
|
||||
}
|
||||
|
||||
const seenLegs = new Set()
|
||||
for (const l of newLegs) {
|
||||
const held = legs.get(l.leg)
|
||||
@@ -1399,6 +1723,9 @@ function apply({
|
||||
for (const t of newTriggers) triggers.set(t.id, { owner, ...t })
|
||||
for (const a of newAudiences) audiences.set(a.id, { owner, ...a })
|
||||
for (const a of newEventActions) eventActions.set(a.id, { owner, ...a })
|
||||
for (const b of newEventBudgets) eventBudgets.set(b.id, { owner, ...b })
|
||||
for (const l of newEventLeases) eventLeases.set(l.id, { owner, ...l })
|
||||
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
|
||||
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
|
||||
}
|
||||
|
||||
@@ -1437,6 +1764,13 @@ function registerCore() {
|
||||
// module, so the registry is exercised on every boot long before a module uses
|
||||
// it — the argument registerCore() has made since the module system's Phase 3.
|
||||
api.registerEventActions(coreEventActions.ACTIONS)
|
||||
// And core's own option source (Phase 7, org lead 2026-09-03). `core.announce`
|
||||
// takes a leg id, and until now that was a free-text box whose typo was caught
|
||||
// at DISPATCH, mid-run — which is exactly the defect Phase 6's walk hit, an
|
||||
// announce leg "site" no module registers. The legs are already in a registry
|
||||
// with their labels, so the dropdown costs nothing new, and core registering an
|
||||
// option source means the seam's first exercise is not a module's.
|
||||
api.registerEventOptionSources(coreEventActions.OPTION_SOURCES)
|
||||
|
||||
// The three lines that used to follow — the shard stream catalog, the town
|
||||
// crier leg and the `admin.users.detail` filling — were shard CONTENT held
|
||||
@@ -1451,6 +1785,7 @@ function registerCore() {
|
||||
streams: streams.length,
|
||||
eventTriggers: triggers.size,
|
||||
eventActions: eventActions.size,
|
||||
eventOptionSources: eventOptionSources.size,
|
||||
announceLegs: legs.size,
|
||||
extensions: [...slots.keys()].filter(slotFilledBy),
|
||||
})
|
||||
@@ -1483,6 +1818,9 @@ function _reset() {
|
||||
triggers.clear()
|
||||
audiences.clear()
|
||||
eventActions.clear()
|
||||
eventBudgets.clear()
|
||||
eventLeases.clear()
|
||||
eventOptionSources.clear()
|
||||
engagementSeeds.clear()
|
||||
coreRegistered = false
|
||||
}
|
||||
@@ -1514,6 +1852,13 @@ module.exports = {
|
||||
allEventActions,
|
||||
eventAction,
|
||||
isEventAction,
|
||||
allEventBudgets,
|
||||
eventBudget,
|
||||
isEventBudget,
|
||||
allEventLeases,
|
||||
eventLease,
|
||||
allEventOptionSources,
|
||||
resolveOptionSource,
|
||||
allEngagementSeeds,
|
||||
engagementSeedsFor,
|
||||
SEEDABLE_CHANNELS,
|
||||
@@ -1522,6 +1867,8 @@ module.exports = {
|
||||
ACTION_RISKS,
|
||||
ACTION_REVERSIBLE,
|
||||
ACTION_PARAM_TYPES,
|
||||
LEASE_TYPES,
|
||||
MAX_LEASE_MS,
|
||||
DEFAULT_BUDGET_MS,
|
||||
stage,
|
||||
apply,
|
||||
|
||||
@@ -9,6 +9,27 @@
|
||||
// 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.10.0 — the event contract opens to modules: `api.registerEventActions`,
|
||||
// `api.registerEventBudgets`, `api.registerEventLeases` and
|
||||
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
|
||||
// Phase 7). Four names, and only one of them is new machinery: the ACTION
|
||||
// registry has staged core's `core.announce`, `core.wait` and `core.cue` on every
|
||||
// boot since Events Phase 1, and `loader.js` simply had no method that delegated
|
||||
// to it. What Phase 7 adds is the facade, the three declarations beside it, and
|
||||
// the fail-closed rule they exist for — a `cost()` naming a dimension no module
|
||||
// registered is REFUSED at save, at the dry run and at dispatch, so a module
|
||||
// cannot spend a budget it did not declare.
|
||||
//
|
||||
// Additions only, so minor: a module written against 1.9.0 registers no actions
|
||||
// and the deployment simply has fewer verbs an event can use. That is §F's own
|
||||
// posture stated as a version rule — core with no module installed is still an
|
||||
// event engine that can announce, wait, cue a human and publish results.
|
||||
//
|
||||
// **A lease is DECLARED here and acquired by nothing.** Core owns a lease's
|
||||
// duration and its conflict check, and both live in the resource ledger, which is
|
||||
// Phase 8's. It is in 1.10.0 rather than in 1.11.0 so that the module contract is
|
||||
// one version a module author reads once, not two.
|
||||
//
|
||||
// 1.9.0 - a sixth registration call: `api.registerEngagementSeeds({ templates,
|
||||
// ruleGroups })` (docs/website/ENGAGEMENT.md Phase 11b, decision 7). A module
|
||||
// could declare a trigger from 1.7.0 and could never say what the mail should
|
||||
@@ -134,6 +155,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.9.0'
|
||||
const MODULE_API_VERSION = '1.10.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
|
||||
Reference in New Issue
Block a user