feat(events): enablement, per-run caps and mayInvoke (Phase 6)
Two new tables — event_action_settings (the deployment switchboard) and event_run_budget (what a run has spent and the most it may) — plus verified_at and verified_by on event_versions. The whole authorisation decision moves behind one function, events/authorize.js: role, enablement, cap, and the shard's own switch named as the layer core deliberately does not duplicate. Three routes, none moved: GET/PUT /admin/events/actions (admin in both directions) and POST /admin/events/:id/verify (admin, editor — a dry run dispatches nothing). Four decisions, settled by the org lead 2026-09-03: - The default-off line falls between inspect and change, not between notify and inspect. Read literally, §K shipped core.wait disabled. The same line is the role floor. - The tightest cap wins where two actions spend one dimension, pinned into the run at creation with the action it came from. - A refusal follows the step's on_failure and takes health to degraded — its own status and its own log kind, because a refusal is not an outage. - The verify gate is enforced for scheduled starts only: a human pressing Start now is the review the gate exists to require. Derived and flagged for review: a dry run fails rather than warns on a disabled action or an over-cap plan, and the unattended path does not re-check the starter's role. +111 tests (1921/1847/73/1 — the one failure pre-existing and environmental), including a 403 walk over the real router and two concurrent spends against one cap on a real MariaDB. The live walk found two defects, both fixed here: the run console route dropped the budget it was handed, and the role refusal used a plural verb over a one-item list. 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:
339
server/src/events/authorize.js
Normal file
339
server/src/events/authorize.js
Normal file
@@ -0,0 +1,339 @@
|
||||
// ── The whole authorisation decision, behind one function ──────────────────
|
||||
//
|
||||
// EVENTS.md §K, and Phase 6 of EVENTS_PLAN.md. Four layers stand between an
|
||||
// action being *declared* and an action being *carried out* — role, enablement,
|
||||
// cap, and the shard's own switch — and §K asks for them in one place rather
|
||||
// than spread across route middleware:
|
||||
//
|
||||
// > **Keep the check in one function.** Not for tidiness: it is what makes an
|
||||
// > EM-style delegation model a *later* option rather than a redesign. If a
|
||||
// > deployment ever wants named coordinators with their own budgets, that is
|
||||
// > one function learning to consult a second table, and nothing else in this
|
||||
// > document changes.
|
||||
//
|
||||
// So `mayInvoke()` below is the only thing in this codebase that answers "may
|
||||
// this happen". The router still calls `requireRole` — that gate is about
|
||||
// reaching the ROUTE — but whether a particular verb may be aimed at the world is
|
||||
// decided here, once, on every path that can cause it: authoring a step,
|
||||
// publishing, the dry run, starting a run, and the runner's own unattended
|
||||
// dispatch.
|
||||
//
|
||||
// ## Four layers, and where each one is actually enforced
|
||||
//
|
||||
// 1. **Declaration** — a module says a verb exists. Not a permission, and not
|
||||
// checked here: `dispatch.js` already answers a step whose action nobody
|
||||
// registers, and it answers it `dormant` rather than `refused`, because an
|
||||
// uninstalled module is a different fact from a forbidden one.
|
||||
// 2. **Role** — `change` and `irreversible` are `admin` only. See below.
|
||||
// 3. **Enablement and caps** — this file, against `event_action_settings` and
|
||||
// `event_run_budget`.
|
||||
// 4. **The shard's own switches** — `AdminWriteEnabled` and `AdminAccessFloor`
|
||||
// live on the shard host, outside the website's reach entirely, and core
|
||||
// deliberately does not duplicate them. A module honours them when it
|
||||
// translates an action into a sidecar command (P9); a second copy of that
|
||||
// decision in core would be a copy that could disagree with the shard about
|
||||
// whether the shard is accepting writes. It is named as a layer because
|
||||
// leaving it unnamed is how it comes to be re-implemented.
|
||||
//
|
||||
// ## The role line, and why it is drawn at `change`
|
||||
//
|
||||
// §K's table says "any step whose action is above `notify`, and the action
|
||||
// switchboard — `admin` only". Read literally that is the same sentence that made
|
||||
// `core.wait` — `risk: 'inspect'` — ship disabled by default, and the org lead
|
||||
// settled that on 2026-09-03: the line falls between `inspect` and `change`, not
|
||||
// between `notify` and `inspect`. An `inspect` action reads state and writes
|
||||
// nothing, so neither the default nor the role floor gains a deployment anything
|
||||
// by excluding it, and an editor who cannot author a step that WAITS has an
|
||||
// authoring role that cannot author.
|
||||
//
|
||||
// ## Why `user` may be null, and what that means
|
||||
//
|
||||
// The runner dispatches with nobody logged in. It is not "the system escalating":
|
||||
// the role was checked when a human published the version and again when a human
|
||||
// or the scheduler started the run, and **a run already in flight is not re-gated
|
||||
// against its starter's current role**. Re-checking would mean that demoting an
|
||||
// admin at midnight silently strands every event they started — an event stopping
|
||||
// halfway through because of an unrelated personnel change. §K's "a demoted user
|
||||
// loses access at once" is about reaching a route, and it still holds exactly
|
||||
// there. Cancel is the control for a run that should stop.
|
||||
//
|
||||
// ## Why the cap check can spend
|
||||
//
|
||||
// `mayInvoke` reads on every path but ONE, and on that one it must also write.
|
||||
// The cap is held by a conditional `UPDATE` whose WHERE carries the guard (§E), so
|
||||
// checking and then spending would be two statements with a race between them —
|
||||
// the exact race the conditional increment exists to remove. `spend: true` is
|
||||
// therefore a parameter rather than a separate function: one decision procedure,
|
||||
// one set of layers, and the authoritative check is the one that also commits.
|
||||
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const budgetDb = require('../model/events/eventRunBudget.db')
|
||||
const registries = require('../modules/registries')
|
||||
const log = require('../utils/logger')('events')
|
||||
|
||||
// The risk classes that change the world, and the two things that follow from
|
||||
// being on this list: the action arrives DISABLED on a fresh deployment, and only
|
||||
// an admin may author a step that names it. Both were one sentence in §K and both
|
||||
// were settled together (org lead, 2026-09-03).
|
||||
const WORLD_CHANGING = ['change', 'irreversible']
|
||||
|
||||
/** Does this action alter the world, in the sense the switchboard and the role floor mean? */
|
||||
const changesWorld = (action) => WORLD_CHANGING.includes(action?.risk)
|
||||
|
||||
/**
|
||||
* Whether an action is enabled, given the deployment's stored opinion — or, when
|
||||
* it has none, its risk class.
|
||||
*
|
||||
* Exported because the switchboard renders the same answer, and a screen that
|
||||
* computed the default itself would be a second copy of the posture.
|
||||
*/
|
||||
function isEnabled(action, settingsRow) {
|
||||
if (settingsRow) return Boolean(settingsRow.enabled)
|
||||
return !changesWorld(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one invocation of `action` costs, as `{dimension: amount}`.
|
||||
*
|
||||
* **A module's `cost()` is called here and nowhere else.** It is declared as a
|
||||
* function of params (§F) and it is called with the params a step actually
|
||||
* carries, so the number core enforces is the number the module said. A `cost`
|
||||
* that throws, or that answers something other than a flat object of
|
||||
* non-negative finite numbers, is treated as an unpriceable action rather than a
|
||||
* free one: `null` comes back, and every caller reads `null` as a refusal. That
|
||||
* is the fail-closed direction, and it is the only honest one — an action whose
|
||||
* own accounting is broken is not an action whose consumption is zero.
|
||||
*/
|
||||
function priceOf(action, params) {
|
||||
if (typeof action?.cost !== 'function') return {}
|
||||
let raw
|
||||
try {
|
||||
raw = action.cost(params || {})
|
||||
} catch (err) {
|
||||
log.warn('event action cost() threw', { action: action.id, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
for (const [dimension, amount] of Object.entries(raw)) {
|
||||
const n = Number(amount)
|
||||
if (!Number.isFinite(n) || n < 0) return null
|
||||
if (n > 0) out[dimension] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimensions an action can spend, discovered by pricing its declared
|
||||
* examples.
|
||||
*
|
||||
* **This is a Phase 6 stand-in with a Phase 7 replacement already named.** §F's
|
||||
* `registerEventBudgets` is what will declare a dimension's id, label and unit,
|
||||
* and it arrives with the module contract. Until then the switchboard still has
|
||||
* to render a cap editor, and it cannot offer a box for a dimension it cannot
|
||||
* name — so core prices each action's own `example` values, which is a use every
|
||||
* param already has a required `example` for.
|
||||
*
|
||||
* It is honest about its limits: a `cost()` that returns different dimension KEYS
|
||||
* for different params under-reports here. That costs an operator a cap box on
|
||||
* the switchboard, and it costs a run nothing at all — a run's budget is seeded
|
||||
* from the params its steps were actually authored with, never from examples.
|
||||
*/
|
||||
function dimensionsOf(action) {
|
||||
const params = {}
|
||||
for (const p of action?.params || []) {
|
||||
if (p.example !== undefined && p.example !== null) params[p.name] = p.example
|
||||
}
|
||||
const priced = priceOf(action, params)
|
||||
return priced ? Object.keys(priced).sort() : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective per-run cap for each dimension a set of steps will spend.
|
||||
*
|
||||
* **The tightest cap wins** (org lead, 2026-09-03). `event_action_settings.caps`
|
||||
* is per action while `event_run_budget` is one row per dimension, so two actions
|
||||
* both spending `uo.creatures` have to agree on one number, and the number a
|
||||
* safety limit should settle on is the smaller. It is what keeps a dimension a
|
||||
* bound on the RUN's total effect rather than a per-verb allowance that two verbs
|
||||
* can each draw in full.
|
||||
*
|
||||
* A dimension no action caps comes back `{ cap: null }` — uncapped, and still
|
||||
* seeded, so the meter counts it and a missing row keeps its one meaning.
|
||||
*
|
||||
* `steps` are `{ actionId, params }`; the answer is `{dimension: {cap, from}}`.
|
||||
*/
|
||||
function effectiveCaps(steps, settingsByAction) {
|
||||
const out = {}
|
||||
for (const step of steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) continue
|
||||
const priced = priceOf(action, step.params)
|
||||
if (!priced) continue
|
||||
const declared = (settingsByAction.get(action.id) || {}).caps || {}
|
||||
for (const dimension of Object.keys(priced)) {
|
||||
const raw = declared[dimension]
|
||||
const cap = Number.isFinite(Number(raw)) && Number(raw) >= 0 ? Number(raw) : null
|
||||
if (!(dimension in out)) {
|
||||
out[dimension] = { cap, from: cap === null ? null : action.id }
|
||||
continue
|
||||
}
|
||||
const held = out[dimension]
|
||||
// `null` is uncapped, so it never wins a minimum — an action that declines
|
||||
// to cap a dimension must not raise the ceiling another action set.
|
||||
if (cap !== null && (held.cap === null || cap < held.cap)) {
|
||||
out[dimension] = { cap, from: action.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* May this action be carried out, and — when asked — spend its cost.
|
||||
*
|
||||
* Answers an envelope, never throws, and never answers a bare boolean: every
|
||||
* refusal carries a `code` a caller can branch on and a `reason` a human reads.
|
||||
* The reason is written here rather than at the four call sites for the same
|
||||
* argument Phase 5 made about the diagnosis panel — one place the words are
|
||||
* written, so the dry run, the editor, the run console and the log all say the
|
||||
* same sentence about the same fact.
|
||||
*
|
||||
* `{ user }` null means the unattended runner; see the header. `{ run }` null
|
||||
* means there is no budget to draw on yet — authoring and the dry run — and the
|
||||
* cap layer then compares the cost against the effective cap instead of against
|
||||
* what is left of it.
|
||||
*/
|
||||
async function mayInvoke({
|
||||
user = null,
|
||||
action,
|
||||
params = {},
|
||||
run = null,
|
||||
settings = undefined,
|
||||
spend = false,
|
||||
} = {}) {
|
||||
if (!action) return { ok: false, code: 'unregistered', reason: 'no module registers this action' }
|
||||
|
||||
// ── Layer 2: the role ──
|
||||
if (user && changesWorld(action) && user.role !== 'admin') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'role',
|
||||
reason: `"${action.label}" changes the world, so only an administrator may use it`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3a: enablement ──
|
||||
const row = settings === undefined ? await settingsDb.get(action.id) : settings
|
||||
if (!isEnabled(action, row)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'disabled',
|
||||
reason: `"${action.label}" is not enabled on this deployment`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layer 3b: the cap ──
|
||||
const cost = priceOf(action, params)
|
||||
if (cost === null) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unpriceable',
|
||||
reason: `"${action.label}" could not report what it costs`,
|
||||
}
|
||||
}
|
||||
const dimensions = Object.keys(cost)
|
||||
if (!dimensions.length) return { ok: true, cost }
|
||||
|
||||
if (!run) {
|
||||
// No run, so nothing to draw on: the question is whether the cost could EVER
|
||||
// fit, which is what the dry run and the editor are asking. A cost larger
|
||||
// than the cap is an authoring error and it is answerable before anything is
|
||||
// scheduled — which is the entire value of catching it here.
|
||||
const caps = effectiveCaps([{ actionId: action.id, params }], new Map([[action.id, row || {}]]))
|
||||
for (const dimension of dimensions) {
|
||||
const { cap } = caps[dimension] || { cap: null }
|
||||
if (cap !== null && cost[dimension] > cap) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${cost[dimension]} of "${dimension}" and this deployment allows ${cap} per run`,
|
||||
dimension,
|
||||
requested: cost[dimension],
|
||||
cap,
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
if (!spend) {
|
||||
// A read of the meter rather than a draw on it. Deliberately advisory: this
|
||||
// answer is stale the moment another step in the same tick spends, which is
|
||||
// exactly why the authoritative check is the one that commits.
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const byDimension = new Map(rows.map((r) => [r.dimension, r]))
|
||||
for (const dimension of dimensions) {
|
||||
const held = byDimension.get(dimension)
|
||||
if (!held) return refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
if (held.cap !== null && held.consumed + cost[dimension] > held.cap) {
|
||||
return refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
}
|
||||
}
|
||||
return { ok: true, cost }
|
||||
}
|
||||
|
||||
// ── The committing path ──
|
||||
//
|
||||
// One statement per dimension, because the atomicity that matters is per
|
||||
// dimension: a cap is a bound on one thing, and a transaction spanning three of
|
||||
// them would serialise three unrelated counters to buy nothing. What it does
|
||||
// create is a partial spend — creatures taken, bosses refused — and a step that
|
||||
// did not run must not have spent anything, so the taken ones are given back.
|
||||
const taken = []
|
||||
for (const dimension of dimensions) {
|
||||
if (await budgetDb.spend(run.id, dimension, cost[dimension])) {
|
||||
taken.push(dimension)
|
||||
continue
|
||||
}
|
||||
for (const back of taken) await budgetDb.refund(run.id, back, cost[back])
|
||||
const rows = await budgetDb.forRun(run.id)
|
||||
const held = rows.find((r) => r.dimension === dimension)
|
||||
return held
|
||||
? refusal(dimension, cost[dimension], held.cap, held.consumed, 'cap')
|
||||
: refusal(dimension, cost[dimension], null, 0, 'unbudgeted')
|
||||
}
|
||||
return { ok: true, cost, spent: true }
|
||||
}
|
||||
|
||||
/** The two cap refusals, written once so they cannot drift apart. */
|
||||
function refusal(dimension, requested, cap, consumed, code) {
|
||||
if (code === 'unbudgeted') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unbudgeted',
|
||||
reason: `spends "${dimension}", which this run has no budget for`,
|
||||
dimension,
|
||||
requested,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'cap',
|
||||
reason: `asks for ${requested} of "${dimension}"; ${consumed} of ${cap} is already spent this run`,
|
||||
dimension,
|
||||
requested,
|
||||
cap,
|
||||
consumed,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mayInvoke,
|
||||
isEnabled,
|
||||
priceOf,
|
||||
dimensionsOf,
|
||||
effectiveCaps,
|
||||
changesWorld,
|
||||
WORLD_CHANGING,
|
||||
}
|
||||
153
server/src/events/verify.js
Normal file
153
server/src/events/verify.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// ── The dry run ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §I ("four affordances worth building in from the start") and §K's
|
||||
// last bound, in Phase 6. Materialise nothing, dispatch every step with
|
||||
// `verify: true`, and report what would happen and what it would cost.
|
||||
//
|
||||
// > **Dry run before anything unattended.** A scheduled definition that has never
|
||||
// > been verified is the case worth refusing to start; verification is cheap and
|
||||
// > it is the last point a human sees the plan.
|
||||
//
|
||||
// **What it verifies depends on the definition's state, and that is not a
|
||||
// compromise.** A `ready` definition is verified against its PUBLISHED VERSION,
|
||||
// because a published version is the only thing that ever actually runs and §K's
|
||||
// gate is about letting one run unattended. A draft is verified against its
|
||||
// working spec, because §API's note is explicit that an author prices their work
|
||||
// *before* asking an admin to publish it. The two readings do not conflict — they
|
||||
// are the same act at two moments — and the answer says which one it did.
|
||||
//
|
||||
// **Only a pass against a version is recorded.** A version is immutable, so a dry
|
||||
// run that passed against one stays true; a draft changes under the author's
|
||||
// hands, so a pass on it would be a claim about a spec that no longer exists.
|
||||
//
|
||||
// ## The finding that only exists here
|
||||
//
|
||||
// Every per-step check — is the action registered, is it enabled, does this one
|
||||
// invocation fit the cap — is a check something else also makes, at save or at
|
||||
// dispatch. **The TOTAL is not.** Three steps each spawning 15 creatures under a
|
||||
// cap of 30 pass every individual check and breach the cap on the third, at two
|
||||
// in the morning, with the world half-changed. Adding the costs up across the
|
||||
// whole version is the one thing that can only be done by looking at the plan as
|
||||
// a whole, and it is the reason a dry run is worth more than the sum of its
|
||||
// step checks.
|
||||
|
||||
const { dispatchStep } = require('./dispatch')
|
||||
const authorize = require('./authorize')
|
||||
const settingsDb = require('../model/events/eventActionSettings.db')
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
/**
|
||||
* Dry-run a spec.
|
||||
*
|
||||
* `user` is the caller, so the role layer answers for *them* — an editor gets
|
||||
* told that a step needs an administrator, at the moment they can still do
|
||||
* something about it, rather than at the moment it does not run.
|
||||
*
|
||||
* Never throws: a `perform()` that explodes under `verify: true` is a finding
|
||||
* about that action, not a 500 on the author's screen. `dispatchStep` already
|
||||
* guarantees that, and this file adds no path around it.
|
||||
*/
|
||||
async function verifySpec(spec, { user = null, scope = '' } = {}) {
|
||||
const phases = spec?.phases || []
|
||||
const flat = []
|
||||
for (const phase of phases) {
|
||||
for (const [seq, step] of (phase.steps || []).entries()) {
|
||||
flat.push({ phase: phase.key, seq, step })
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await settingsDb.byIds(flat.map(({ step }) => step.actionId))
|
||||
const findings = []
|
||||
const totals = {}
|
||||
|
||||
for (const { phase, seq, step } of flat) {
|
||||
const where = { phase, seq, actionId: step.actionId, label: step.label || null }
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (!action) {
|
||||
// The same fact `publishable()` refuses on, said in the dry run's voice.
|
||||
// Reported rather than thrown so that an author sees EVERY problem in one
|
||||
// pass — a verification that stops at the first finding makes fixing a
|
||||
// twelve-step definition twelve round trips.
|
||||
findings.push({ ...where, level: 'error', code: 'dormant', message: `no module registers "${step.actionId}"` })
|
||||
continue
|
||||
}
|
||||
|
||||
const verdict = await authorize.mayInvoke({
|
||||
user,
|
||||
action,
|
||||
params: step.params || {},
|
||||
settings: settings.get(action.id) || null,
|
||||
})
|
||||
if (!verdict.ok) {
|
||||
findings.push({ ...where, level: 'error', code: verdict.code, message: verdict.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [dimension, amount] of Object.entries(verdict.cost || {})) {
|
||||
totals[dimension] = (totals[dimension] || 0) + amount
|
||||
}
|
||||
|
||||
// The module's own answer. This is the half core cannot compute: whether the
|
||||
// landmark exists, whether the creature is on the allowlist, whether the
|
||||
// shard is reachable at all. `verify: true` rides through the real
|
||||
// dispatcher rather than down a second path, because a dry run down a second
|
||||
// path is a dry run OF the second path.
|
||||
const result = await dispatchStep(
|
||||
{
|
||||
id: null,
|
||||
run_id: null,
|
||||
phase,
|
||||
seq,
|
||||
action_id: step.actionId,
|
||||
params: step.params || {},
|
||||
action_version: step.actionVersion || null,
|
||||
idempotency_key: null,
|
||||
attempts: 0,
|
||||
},
|
||||
{ run: { id: null, scope }, actor: user ? user.id : null, verify: true },
|
||||
)
|
||||
if (result.outcome === 'retry' || result.outcome === 'terminal') {
|
||||
findings.push({ ...where, level: 'error', code: 'refused', message: result.error })
|
||||
} else if (result.actionVersionDrift) {
|
||||
findings.push({
|
||||
...where,
|
||||
level: 'warning',
|
||||
code: 'version-drift',
|
||||
message: `authored against version ${result.actionVersionDrift.authored}; ${step.actionId} is now version ${result.actionVersionDrift.registered}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── The whole-plan check ──
|
||||
const caps = authorize.effectiveCaps(
|
||||
flat.map(({ step }) => ({ actionId: step.actionId, params: step.params || {} })),
|
||||
settings,
|
||||
)
|
||||
const cost = Object.entries(totals)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dimension, total]) => {
|
||||
const cap = (caps[dimension] || {}).cap ?? null
|
||||
const over = cap !== null && total > cap
|
||||
if (over) {
|
||||
findings.push({
|
||||
phase: null,
|
||||
seq: null,
|
||||
actionId: null,
|
||||
label: null,
|
||||
level: 'error',
|
||||
code: 'cap-total',
|
||||
message: `this event asks for ${total} of "${dimension}" across all its steps, and this deployment allows ${cap} per run`,
|
||||
})
|
||||
}
|
||||
return { dimension, total, cap, from: (caps[dimension] || {}).from || null, over }
|
||||
})
|
||||
|
||||
return {
|
||||
ok: !findings.some((f) => f.level === 'error'),
|
||||
steps: flat.length,
|
||||
findings,
|
||||
cost,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifySpec }
|
||||
86
server/src/model/events/eventActionSettings.db.js
Normal file
86
server/src/model/events/eventActionSettings.db.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// ── event_action_settings — SQL only ───────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard:
|
||||
// one row per action an admin has an opinion about, and **nothing else in the
|
||||
// permission model beyond the role**.
|
||||
//
|
||||
// **A missing row is not "disabled".** It is "the default for this action's risk
|
||||
// class", and that default is computed in `eventActionSettings.model.js` from the
|
||||
// registry rather than stored here. The reason is structural: the registry is
|
||||
// assembled by `registerCore()` and by module `register()`, both of which run
|
||||
// under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md
|
||||
// §2.2), so a boot-time seed of one row per registered action would be precisely
|
||||
// the database write those two forbid. A deployment that never opens the
|
||||
// switchboard has no rows at all and behaves correctly.
|
||||
//
|
||||
// **Rows outlive their actions on purpose.** Uninstalling a module leaves its
|
||||
// settings standing, so re-installing restores the caps the operator chose
|
||||
// instead of silently resetting them to the default. The switchboard lists what
|
||||
// is registered *now*, so a stranded row is invisible until its action returns.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
const { parseJson } = require('./eventJson')
|
||||
|
||||
const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} }
|
||||
|
||||
/** Every stored row, action id first. Stranded rows included — the caller filters. */
|
||||
async function all() {
|
||||
const rows = await query(
|
||||
`SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username
|
||||
FROM event_action_settings s
|
||||
LEFT JOIN users u ON u.id = s.updated_by
|
||||
ORDER BY s.action_id`,
|
||||
)
|
||||
return rows.map(hydrate)
|
||||
}
|
||||
|
||||
/** One row, or null when the deployment has never had an opinion about this action. */
|
||||
async function get(actionId) {
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings WHERE action_id = ?`,
|
||||
[actionId],
|
||||
)
|
||||
return rows.length ? hydrate(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows for a set of action ids, as a Map keyed by id.
|
||||
*
|
||||
* The shape the authorisation path wants: `mayInvoke` is asked about one action
|
||||
* at a time but the run start prices a whole version at once, and one query per
|
||||
* step of a twelve-step definition is twelve round trips to answer a question
|
||||
* about a table with one row per action in the process.
|
||||
*/
|
||||
async function byIds(actionIds) {
|
||||
const ids = [...new Set(actionIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT action_id, enabled, caps, updated_by, updated_at
|
||||
FROM event_action_settings
|
||||
WHERE action_id IN (${ids.map(() => '?').join(',')})`,
|
||||
ids,
|
||||
)
|
||||
return new Map(rows.map((r) => [r.action_id, hydrate(r)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one action's switch and caps.
|
||||
*
|
||||
* An upsert rather than a read-then-write, for the ordinary reason: two admins on
|
||||
* the switchboard at once should leave one of their two opinions standing, not an
|
||||
* error and not a row that never appeared. There is no compare-and-set here
|
||||
* because there is nothing to race — this is configuration, and the value that
|
||||
* matters is the last one a human chose.
|
||||
*/
|
||||
async function put(actionId, { enabled, caps }, userId = null) {
|
||||
await query(
|
||||
`INSERT INTO event_action_settings (action_id, enabled, caps, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`,
|
||||
[actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId],
|
||||
)
|
||||
return get(actionId)
|
||||
}
|
||||
|
||||
module.exports = { all, get, byIds, put }
|
||||
@@ -16,9 +16,16 @@ const hydrate = (row) =>
|
||||
// `current_version` is joined rather than stored: the list screen shows "v3" and
|
||||
// the column that would hold it is a denormalisation of a row this query already
|
||||
// has to reach for the publish date anyway.
|
||||
//
|
||||
// `current_version_verified_at` rides along for the same reason and answers the
|
||||
// same kind of question (Phase 6). A `ready` definition whose version has never
|
||||
// been dry-run will not start on its schedule (§K), and the one place that fact
|
||||
// is worth saying is the screen its author is already looking at -- the
|
||||
// alternative is finding out on the Friday it did not run.
|
||||
const SELECT_LIST = `
|
||||
SELECT d.*, s.name AS series_name, s.slug AS series_slug,
|
||||
v.version AS current_version
|
||||
v.version AS current_version,
|
||||
v.verified_at AS current_version_verified_at
|
||||
FROM event_definitions d
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
LEFT JOIN event_versions v ON v.id = d.current_version_id
|
||||
|
||||
@@ -26,6 +26,9 @@ const runsDb = require('./eventRuns.db')
|
||||
const logDb = require('./eventRunLog.db')
|
||||
const seriesDb = require('./eventSeries.db')
|
||||
const spec = require('../../events/spec')
|
||||
const authorize = require('../../events/authorize')
|
||||
const verifier = require('../../events/verify')
|
||||
const registries = require('../../modules/registries')
|
||||
const { slugify, uniqueSlug } = require('../teams/teamSlug')
|
||||
const { cleanBody } = require('../../utils/sanitizeHtml')
|
||||
|
||||
@@ -162,9 +165,11 @@ async function validate(input, { existing = null } = {}) {
|
||||
}
|
||||
|
||||
/** Create a draft. */
|
||||
async function create(input, userId) {
|
||||
async function create(input, userId, { role = null } = {}) {
|
||||
const result = await validate(input)
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
const id = await db.insert({ ...result.definition, created_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -177,7 +182,7 @@ async function create(input, userId) {
|
||||
* retired, and a retired definition that can still be edited is a definition
|
||||
* somebody will edit and then wonder why it never runs.
|
||||
*/
|
||||
async function save(id, input, userId) {
|
||||
async function save(id, input, userId, { role = null } = {}) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
@@ -185,6 +190,8 @@ async function save(id, input, userId) {
|
||||
}
|
||||
const result = await validate(input, { existing })
|
||||
if (!result.ok) return result
|
||||
const floor = checkRoleFloor(result.definition.spec, role)
|
||||
if (floor) return floor
|
||||
await db.update(id, { ...result.definition, updated_by: userId })
|
||||
return { ok: true, id, definition: await db.getById(id) }
|
||||
}
|
||||
@@ -283,12 +290,113 @@ async function archive(id, userId) {
|
||||
return { ok: true, definition: await db.getById(id) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The role floor on a spec's steps (§K, Phase 6).
|
||||
*
|
||||
* §K's table reads "any step whose action is above `notify` — `admin` only", and
|
||||
* the line falls between `inspect` and `change` for the reason the default-off
|
||||
* rule does (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
* nothing, and an editor who cannot author a step that WAITS has an authoring
|
||||
* role that cannot author.
|
||||
*
|
||||
* Checked at SAVE rather than only at publish, which is the difference between
|
||||
* telling an editor now and telling them after they have written twelve steps.
|
||||
* Publish re-checks anyway — it re-checks everything, against the registries as
|
||||
* they stand at that moment — because an action's risk class is a module's
|
||||
* declaration and a module can be upgraded between the two.
|
||||
*/
|
||||
function worldChangingSteps(specValue) {
|
||||
const out = []
|
||||
for (const phase of specValue?.phases || []) {
|
||||
for (const step of phase.steps || []) {
|
||||
const action = registries.eventAction(step.actionId)
|
||||
if (action && authorize.changesWorld(action)) out.push(action)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function checkRoleFloor(specValue, role) {
|
||||
if (!role || role === 'admin') return null
|
||||
const blocked = worldChangingSteps(specValue)
|
||||
if (!blocked.length) return null
|
||||
const names = [...new Set(blocked.map((a) => `"${a.label}"`))]
|
||||
// Agreement, because this sentence is read by the person it refuses. The list
|
||||
// is almost always one long -- an editor adds one world-changing step and is
|
||||
// stopped -- so `"Spawn creatures" change the world` is the case that shows,
|
||||
// and it reads as a bug in the sentence rather than a rule about the step.
|
||||
const one = names.length === 1
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
errors: [
|
||||
`${names.join(', ')} ${one ? 'changes' : 'change'} the world, so only an administrator may author a step that uses ${one ? 'it' : 'them'}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dry-run a definition, and record the pass when there is a version to record it
|
||||
* on (Phase 6).
|
||||
*
|
||||
* The target follows the definition's state: a `ready` definition is verified
|
||||
* against the version that would actually run, a draft against the working spec
|
||||
* the author is still holding. See `events/verify.js` for why that is one act at
|
||||
* two moments rather than two rules.
|
||||
*/
|
||||
async function verify(id, user) {
|
||||
const existing = await db.getById(id)
|
||||
if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] }
|
||||
if (existing.state === 'archived') {
|
||||
return { ok: false, status: 409, errors: ['an archived definition cannot be verified'] }
|
||||
}
|
||||
|
||||
const version =
|
||||
existing.state === 'ready' && existing.current_version_id
|
||||
? await versionsDb.getById(existing.current_version_id)
|
||||
: null
|
||||
const target = version?.spec || existing.spec
|
||||
if (!target?.phases?.length) {
|
||||
return { ok: false, status: 409, errors: ['this definition has no phases to verify'] }
|
||||
}
|
||||
|
||||
const report = await verifier.verifySpec(target, { user })
|
||||
|
||||
if (version && report.ok) {
|
||||
await versionsDb.markVerified(version.id, user?.id || null)
|
||||
// Written to every run already pinned to this version, because that is where
|
||||
// an operator asks the question: a scheduled occurrence that was being held
|
||||
// is now going to start, and the line saying why belongs on it.
|
||||
for (const run of await runsDb.listScheduledFor(id)) {
|
||||
if (Number(run.version_id) !== Number(version.id)) continue
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
kind: 'version.verified',
|
||||
detail: { versionId: version.id, version: version.version, by: user?.id || null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
report,
|
||||
// Which spec was verified, said plainly, because the two answer different
|
||||
// questions and a report that did not say would be read as the other one.
|
||||
target: version ? 'version' : 'draft',
|
||||
versionId: version?.id || null,
|
||||
version: version?.version || null,
|
||||
recorded: Boolean(version && report.ok),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validate,
|
||||
create,
|
||||
save,
|
||||
publish,
|
||||
archive,
|
||||
verify,
|
||||
checkRoleFloor,
|
||||
isTimezone,
|
||||
MIN_GRACE_SECONDS,
|
||||
MAX_GRACE_SECONDS,
|
||||
|
||||
134
server/src/model/events/eventRunBudget.db.js
Normal file
134
server/src/model/events/eventRunBudget.db.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── event_run_budget — SQL only ────────────────────────────────────────────
|
||||
//
|
||||
// EVENTS.md §D/§E, and Phase 6 of EVENTS_PLAN.md. What one run has spent of one
|
||||
// dimension, and the most it may.
|
||||
//
|
||||
// **The whole file exists for one statement.** `spend()` is the conditional
|
||||
// increment §E names as the answer to "two steps spending one cap":
|
||||
//
|
||||
// UPDATE … SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap
|
||||
//
|
||||
// A read-then-write would let two steps drawing on `uo.creatures` in the same
|
||||
// tick each see 28 of 30 and each spend 5. The cap in the WHERE means the second
|
||||
// one changes no rows, and `affectedRows === 0` *is* the refusal — no transaction,
|
||||
// no lock, and no second opinion about the arithmetic. Same shape as the outbox
|
||||
// claim, `runsDb.transition` and Phase 5's gate increment, and the same argument.
|
||||
//
|
||||
// **The SET list here has one assignment for the reason Phase 5's had three.**
|
||||
// MariaDB evaluates an UPDATE's SET assignments left to right, each seeing what
|
||||
// the ones before it assigned — which is how Phase 5's gate closed a firing early
|
||||
// — so nothing in this statement may read `consumed` after it has been written.
|
||||
// The guard is in the WHERE, where it reads the pre-update row, and it must stay
|
||||
// there.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
/**
|
||||
* Seed a run's budget rows.
|
||||
*
|
||||
* **INSERT IGNORE against `uq_evbud_dim`**, so a tick that overruns into the next
|
||||
* one cannot double-seed and cannot reset a cap a run has already spent against —
|
||||
* the idempotence `materialisePhase` and `gates.open` both have, for the same
|
||||
* reason.
|
||||
*
|
||||
* The caps are COPIED here rather than read live at dispatch. A run pins its
|
||||
* version and is reproducible in every other respect; a cap read live would be
|
||||
* the one input to a run's behaviour an admin could change underneath it while
|
||||
* nobody was watching, and the console's meter would answer "what is allowed now"
|
||||
* when the question afterwards is "what was this run allowed".
|
||||
*/
|
||||
async function seed(runId, dimensions) {
|
||||
const rows = Object.entries(dimensions || {})
|
||||
if (!rows.length) return 0
|
||||
const values = rows.map(() => '(?, ?, 0, ?, ?)').join(', ')
|
||||
const params = rows.flatMap(([dimension, d]) => [runId, dimension, d.cap, d.from || null])
|
||||
const result = await query(
|
||||
`INSERT IGNORE INTO event_run_budget (run_id, dimension, consumed, cap, effective_from)
|
||||
VALUES ${values}`,
|
||||
params,
|
||||
)
|
||||
return result.affectedRows || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend `amount` of one dimension, or refuse.
|
||||
*
|
||||
* Answers `true` when the row moved and `false` when it did not — and `false` has
|
||||
* exactly two causes, both of which mean the same thing to the caller: the spend
|
||||
* would breach the cap, or there is no row for this dimension at all. The second
|
||||
* is not a silent pass: a run whose version names an action that costs a
|
||||
* dimension always has that dimension seeded — **uncapped ones included, as a row
|
||||
* with a NULL cap** — so a missing row means the step is spending something its
|
||||
* own version never declared, and refusing that is the fail-closed direction.
|
||||
*
|
||||
* A zero or negative amount is not a spend and never touches the database. An
|
||||
* action whose `cost()` answers `0` for its params is telling core it consumes
|
||||
* nothing, and pricing that as a query would put one round trip per step behind a
|
||||
* fact the caller already has.
|
||||
*/
|
||||
async function spend(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return true
|
||||
const result = await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)`,
|
||||
[amount, runId, dimension, amount],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `amount` back.
|
||||
*
|
||||
* Called on exactly one path: a step that spent several dimensions and was then
|
||||
* refused on a later one. The spends are separate statements — they must be, the
|
||||
* atomicity that matters is per dimension — so a step costing 5 creatures and 2
|
||||
* bosses can take the creatures and be refused the bosses, and a step that did
|
||||
* not run must not have spent anything. `GREATEST(consumed - ?, 0)` because the
|
||||
* floor is worth more than a refund that is exactly right: a negative `consumed`
|
||||
* would make the cap arithmetic lie in the permissive direction forever after.
|
||||
*/
|
||||
async function refund(runId, dimension, amount) {
|
||||
if (!(amount > 0)) return
|
||||
await query(
|
||||
`UPDATE event_run_budget
|
||||
SET consumed = GREATEST(consumed - ?, 0)
|
||||
WHERE run_id = ? AND dimension = ?`,
|
||||
[amount, runId, dimension],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every dimension of one run, for the console's meter. */
|
||||
async function forRun(runId) {
|
||||
return query(
|
||||
`SELECT dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget WHERE run_id = ? ORDER BY dimension`,
|
||||
[runId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The dimensions of several runs at once, keyed by run id — the run LIST's read. */
|
||||
async function forRuns(runIds) {
|
||||
const ids = [...new Set(runIds || [])].filter(Boolean)
|
||||
if (!ids.length) return new Map()
|
||||
const rows = await query(
|
||||
`SELECT run_id, dimension, consumed, cap, effective_from
|
||||
FROM event_run_budget
|
||||
WHERE run_id IN (${ids.map(() => '?').join(',')})
|
||||
ORDER BY run_id, dimension`,
|
||||
ids,
|
||||
)
|
||||
const out = new Map()
|
||||
for (const r of rows) {
|
||||
if (!out.has(r.run_id)) out.set(r.run_id, [])
|
||||
out.get(r.run_id).push({
|
||||
dimension: r.dimension,
|
||||
consumed: r.consumed,
|
||||
cap: r.cap,
|
||||
effective_from: r.effective_from,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = { seed, spend, refund, forRun, forRuns }
|
||||
@@ -40,6 +40,13 @@ const KINDS = [
|
||||
'phase.gate', // a phase opened an advance gate, with what it waits for
|
||||
'condition.evaluated', // a firing was tested against a gate, matched or not
|
||||
'phase.advanced', // a gate opened: on a firing, on its deadline, or forced
|
||||
// Phase 6's three. `step.refused` is the one worth naming separately from
|
||||
// `step.status`: a refusal is not a failure, and an operator reading a run that
|
||||
// stopped needs to see at a glance that nothing is broken -- the deployment
|
||||
// simply does not permit what the author asked for.
|
||||
'run.budget', // the caps this run was seeded with, and which switch set each
|
||||
'step.refused', // a step was not permitted: disabled, or over a cap
|
||||
'version.verified', // a dry run passed against a version, unlocking scheduled starts
|
||||
]
|
||||
|
||||
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
||||
|
||||
@@ -25,6 +25,9 @@ const gatesDb = require('./eventPhaseGates.db')
|
||||
const gates = require('../../events/gates')
|
||||
const definitionsDb = require('./eventDefinitions.db')
|
||||
const versionsDb = require('./eventVersions.db')
|
||||
const settingsDb = require('./eventActionSettings.db')
|
||||
const budgetDb = require('./eventRunBudget.db')
|
||||
const authorize = require('../../events/authorize')
|
||||
|
||||
const MAX_SCOPE = 190
|
||||
|
||||
@@ -85,6 +88,22 @@ async function create(
|
||||
return { ok: false, status: 409, errors: ['the published version has no phases'] }
|
||||
}
|
||||
|
||||
// §K's last bound, enforced for SCHEDULED starts only (org lead, 2026-09-03):
|
||||
// *"a scheduled definition that has never been verified is the case worth
|
||||
// refusing to start"*. An admin pressing start is watching, and that human IS
|
||||
// the review the gate exists to require — so the gate falls on the path where
|
||||
// nobody is. A version is immutable, so a dry run that passed against it stays
|
||||
// true, which is what makes the pass a property of the version rather than
|
||||
// something re-earned every occurrence.
|
||||
if (source === 'schedule' && !version.verified_at) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: 'unverified',
|
||||
errors: ['this version has not been verified, so it will not start unattended'],
|
||||
}
|
||||
}
|
||||
|
||||
const scopeValue = String(scope || '').slice(0, MAX_SCOPE)
|
||||
const when = scheduledFor ? new Date(scheduledFor) : new Date()
|
||||
if (Number.isNaN(when.getTime())) {
|
||||
@@ -130,6 +149,32 @@ async function create(
|
||||
},
|
||||
})
|
||||
|
||||
// The run's budget, seeded from EVERY phase's steps rather than from the first
|
||||
// one's (Phase 6). The version is pinned and immutable, so all of its steps are
|
||||
// knowable now — and a budget that grew as phases were entered would let a
|
||||
// phase-1 step spend a cap that a phase-3 step was going to need, which is the
|
||||
// opposite of a per-run bound. The caps are copied here, so an admin moving a
|
||||
// switch tomorrow does not change what a run already in flight is allowed.
|
||||
const allSteps = version.spec.phases.flatMap((p) =>
|
||||
(p.steps || []).map((s) => ({ actionId: s.actionId, params: s.params || {} })),
|
||||
)
|
||||
const settingsByAction = await settingsDb.byIds(allSteps.map((s) => s.actionId))
|
||||
const budget = authorize.effectiveCaps(allSteps, settingsByAction)
|
||||
if (Object.keys(budget).length) {
|
||||
await budgetDb.seed(runId, budget)
|
||||
await logDb.write({
|
||||
runId,
|
||||
kind: 'run.budget',
|
||||
detail: {
|
||||
dimensions: Object.entries(budget).map(([dimension, d]) => ({
|
||||
dimension,
|
||||
cap: d.cap,
|
||||
from: d.from,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The first phase's steps, materialised at creation rather than at start.
|
||||
// Phase 2 materialises each LATER phase as the run enters it; doing the first
|
||||
// one here is what makes a Phase 1 run row inspectable — an operator can see
|
||||
@@ -159,13 +204,29 @@ async function create(
|
||||
async function detail(runId) {
|
||||
const run = await db.getById(runId)
|
||||
if (!run) return null
|
||||
const [steps, counts, gateRows] = await Promise.all([
|
||||
const [steps, counts, gateRows, budget] = await Promise.all([
|
||||
stepsDb.listForRun(runId),
|
||||
stepsDb.statusCounts(runId),
|
||||
gatesDb.listForRun(runId),
|
||||
budgetDb.forRun(runId),
|
||||
])
|
||||
const now = new Date()
|
||||
return { run, steps, counts, gates: gateRows.map((g) => gates.describe(g, now)) }
|
||||
return {
|
||||
run,
|
||||
steps,
|
||||
counts,
|
||||
gates: gateRows.map((g) => gates.describe(g, now)),
|
||||
// The meter, as rows rather than as a sentence: a cap is two numbers and a
|
||||
// name, and unlike a gate it needs no grammar rendered to be read. `cap:
|
||||
// null` is uncapped and the client says so — a dimension the run counts but
|
||||
// nothing bounds.
|
||||
budget: budget.map((b) => ({
|
||||
dimension: b.dimension,
|
||||
consumed: b.consumed,
|
||||
cap: b.cap,
|
||||
from: b.effective_from,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { create, detail, renderConcurrencyKey }
|
||||
|
||||
@@ -51,4 +51,24 @@ const insert = async (definitionId, version, spec, userId) => {
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
module.exports = { listForDefinition, getById, nextVersion, insert }
|
||||
/**
|
||||
* Record that a dry run passed against this version (Phase 6).
|
||||
*
|
||||
* A version is immutable in every respect that describes the EVENT — its spec,
|
||||
* its number, who published it. These two columns describe something that
|
||||
* happened to it afterwards, which is why they can be written at all: a pass is
|
||||
* a fact about a review, not a change to the plan reviewed.
|
||||
*
|
||||
* Deliberately not idempotent-checked: verifying twice stamps the second one, and
|
||||
* the later reviewer is the more useful answer to "who last looked at this
|
||||
* before it ran unattended".
|
||||
*/
|
||||
const markVerified = async (id, userId, at = new Date()) => {
|
||||
const result = await query(
|
||||
'UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?',
|
||||
[at, userId, id],
|
||||
)
|
||||
return (result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
module.exports = { listForDefinition, getById, nextVersion, insert, markVerified }
|
||||
|
||||
@@ -32,6 +32,8 @@ const runs = require('../../../model/events/eventRuns.model')
|
||||
const controls = require('../../../model/events/eventRunControls.model')
|
||||
const logDb = require('../../../model/events/eventRunLog.db')
|
||||
const activity = require('../../../model/activity/activity.model')
|
||||
const settingsDb = require('../../../model/events/eventActionSettings.db')
|
||||
const authorize = require('../../../events/authorize')
|
||||
|
||||
const asId = (raw) => {
|
||||
const n = Number(raw)
|
||||
@@ -57,6 +59,10 @@ const shapeDefinition = (d) => ({
|
||||
state: d.state,
|
||||
currentVersionId: d.current_version_id,
|
||||
currentVersion: d.current_version,
|
||||
// §K's gate, rendered where it can still be acted on. `null` on a draft --
|
||||
// there is no version to have verified -- and a date once a dry run has passed
|
||||
// against the published one.
|
||||
currentVersionVerifiedAt: d.current_version_verified_at || null,
|
||||
seriesId: d.series_id,
|
||||
seriesName: d.series_name,
|
||||
seriesOrder: d.series_order,
|
||||
@@ -289,6 +295,11 @@ exports.getRun = async (req, res) => {
|
||||
// `eventRuns.model.detail` for why the sentence is built here and not in
|
||||
// the browser.
|
||||
gates: found.gates,
|
||||
// The caps this run was given and what it has spent (Phase 6). Copied into
|
||||
// the run when it was created, so it answers "what was THIS run allowed"
|
||||
// rather than "what is allowed now" — which is the question that survives
|
||||
// an admin moving a switch tomorrow.
|
||||
budget: found.budget,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -342,7 +353,7 @@ exports.listVersions = async (req, res) => {
|
||||
|
||||
/** POST /api/v1/admin/events */
|
||||
exports.create = async (req, res) => {
|
||||
const result = await definitions.create(req.body, req.user.id)
|
||||
const result = await definitions.create(req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
@@ -356,7 +367,7 @@ exports.create = async (req, res) => {
|
||||
exports.update = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.save(id, req.body, req.user.id)
|
||||
const result = await definitions.save(id, req.body, req.user.id, { role: req.user.role })
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
@@ -388,6 +399,139 @@ exports.publish = async (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/events/:id/verify — the dry run.
|
||||
*
|
||||
* `admin, editor` rather than `admin` (§ API surface): a dry run dispatches
|
||||
* nothing and changes nothing, and the author who wrote the definition is
|
||||
* exactly who should be able to price it against the caps before asking an
|
||||
* admin to publish it.
|
||||
*
|
||||
* **A report with findings is a 200, not a 400.** The request succeeded; the
|
||||
* plan has problems. Answering 4xx would make "this event asks for 45 creatures
|
||||
* and you allow 30" indistinguishable to the client from "you sent a bad event
|
||||
* id", and the whole value of the screen is rendering the findings.
|
||||
*/
|
||||
exports.verify = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
if (!id) return res.status(400).json({ error: 'bad event id' })
|
||||
const result = await definitions.verify(id, req.user)
|
||||
if (!result.ok) return res.status(result.status || 400).json({ errors: result.errors })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.definition.verified',
|
||||
detail: {
|
||||
id,
|
||||
target: result.target,
|
||||
versionId: result.versionId,
|
||||
passed: result.report.ok,
|
||||
findings: result.report.findings.length,
|
||||
},
|
||||
})
|
||||
return res.json({
|
||||
target: result.target,
|
||||
versionId: result.versionId,
|
||||
version: result.version,
|
||||
recorded: result.recorded,
|
||||
report: result.report,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/events/actions — the deployment's switchboard.
|
||||
*
|
||||
* Every registered action, each with the deployment's stored opinion of it or,
|
||||
* where there is none, **the default its risk class implies**. The default is
|
||||
* computed by `authorize.isEnabled` rather than here, because a screen that
|
||||
* worked out the posture for itself would be a second copy of the posture, and
|
||||
* the copy that drifts is always the one on the screen.
|
||||
*
|
||||
* `configured` says whether a row exists, which the client needs to tell "an
|
||||
* admin turned this on" from "this has always been on" — the same fact, arrived
|
||||
* at two ways, and only one of them is a decision somebody made.
|
||||
*/
|
||||
exports.actions = async (_req, res) => {
|
||||
const all = registries.allEventActions()
|
||||
const stored = await settingsDb.byIds(all.map((a) => a.id))
|
||||
return res.json({
|
||||
actions: all.map((a) => {
|
||||
const row = stored.get(a.id) || null
|
||||
const full = registries.eventAction(a.id)
|
||||
return {
|
||||
...a,
|
||||
enabled: authorize.isEnabled(full, row),
|
||||
configured: Boolean(row),
|
||||
changesWorld: authorize.changesWorld(full),
|
||||
// The dimensions this action can spend, so the screen can offer a cap
|
||||
// box per dimension. Discovered by pricing the action's own declared
|
||||
// examples until §F's `registerEventBudgets` lands in Phase 7 — see
|
||||
// `authorize.dimensionsOf`.
|
||||
dimensions: authorize.dimensionsOf(full),
|
||||
caps: row?.caps || {},
|
||||
updatedAt: row?.updated_at || null,
|
||||
updatedBy: row?.updated_by_username || null,
|
||||
}
|
||||
}),
|
||||
// The rule the screen explains to the operator, served rather than written
|
||||
// into the client twice.
|
||||
worldChangingRisks: authorize.WORLD_CHANGING,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/admin/events/actions — set one action's switch and caps.
|
||||
*
|
||||
* One action per request rather than the whole board: the board is rendered from
|
||||
* the registry and a whole-board PUT would have to say what an action MISSING
|
||||
* from the body means. On a screen listing what is registered right now, that is
|
||||
* "a module booted between the GET and the PUT", and answering it by writing a
|
||||
* default over an admin's stored choice is the kind of quiet data loss a sparse
|
||||
* write does not have.
|
||||
*/
|
||||
exports.saveAction = async (req, res) => {
|
||||
const actionId = String(req.body?.actionId || '')
|
||||
const action = registries.eventAction(actionId)
|
||||
if (!action) return res.status(404).json({ error: 'no module registers that action' })
|
||||
|
||||
if (typeof req.body?.enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be true or false' })
|
||||
}
|
||||
|
||||
// Caps are validated against the dimensions this action can actually spend.
|
||||
// A cap on a dimension it never names is not a harmless extra row — it is a
|
||||
// number an operator believes is protecting them, on a screen that would
|
||||
// render it back to them forever, bounding nothing.
|
||||
const known = new Set(authorize.dimensionsOf(action))
|
||||
const caps = {}
|
||||
for (const [dimension, raw] of Object.entries(req.body?.caps || {})) {
|
||||
if (raw === null || raw === '') continue
|
||||
if (!known.has(dimension)) {
|
||||
return res.status(400).json({ error: `"${action.id}" does not spend "${dimension}"` })
|
||||
}
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
return res.status(400).json({ error: `the cap for "${dimension}" must be a whole number of 0 or more` })
|
||||
}
|
||||
caps[dimension] = n
|
||||
}
|
||||
|
||||
const row = await settingsDb.put(actionId, { enabled: req.body.enabled, caps }, req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'event.action.configured',
|
||||
detail: { actionId, enabled: Boolean(req.body.enabled), caps },
|
||||
})
|
||||
return res.json({
|
||||
action: {
|
||||
id: actionId,
|
||||
enabled: Boolean(row.enabled),
|
||||
configured: true,
|
||||
caps: row.caps,
|
||||
updatedAt: row.updated_at,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/admin/events/:id — archive, never a hard delete */
|
||||
exports.archive = async (req, res) => {
|
||||
const id = asId(req.params.id)
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
// gate nobody notices was missing.
|
||||
//
|
||||
// Reads are staff-wide. The live run controls landed in Phase 3 and are `admin`
|
||||
// + `moderator`, deliberately wider than start (§N2). `verify` (admin, editor),
|
||||
// `advance`, `cleanup` and the action switchboard are still absent rather than
|
||||
// stubbed — there is no advance condition until Phase 5, no resource ledger
|
||||
// until Phase 8 and no caps to price against until Phase 6.
|
||||
// + `moderator`, deliberately wider than start (§N2). `advance` arrived in Phase
|
||||
// 5; **`verify` and the action switchboard arrived in Phase 6** — `verify` at
|
||||
// `admin, editor` because a dry run dispatches nothing, and both halves of
|
||||
// `/actions` at `admin`, because §K puts the switchboard in the same row as the
|
||||
// world-changing actions it governs. `cleanup` is still absent rather than
|
||||
// stubbed: there is no resource ledger until Phase 8.
|
||||
//
|
||||
// **Literal paths are declared before `/:id`**, so `/catalog`, `/series`,
|
||||
// `/calendar` and `/runs` are never read as an event id.
|
||||
@@ -52,6 +54,42 @@ eventsRouter.get(
|
||||
controller.catalog,
|
||||
)
|
||||
|
||||
// ── The switchboard (Phase 6) ──────────────────────────────────────────────
|
||||
//
|
||||
// A literal path, so it is declared up here with `/catalog` rather than beside
|
||||
// the definition routes -- `/:id` would otherwise read `actions` as an event id.
|
||||
// Both halves are `adminOnly`: §K puts the action switchboard in the same row as
|
||||
// the world-changing actions it governs, because deciding what a deployment may
|
||||
// do at all is configuration that can break things, which is exactly the line
|
||||
// module-uo's split already draws.
|
||||
|
||||
eventsRouter.get(
|
||||
'/actions',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Which actions are enabled on this deployment, and their per-run caps'
|
||||
// #swagger.description = 'The deployment switchboard (EVENTS.md K, Phase 6). One entry per action registered on THIS boot, each carrying the deployment stored opinion of it or, where there is none, the default its risk class implies: change and irreversible actions arrive disabled, notify and inspect arrive enabled. `configured` says whether a row exists at all, which is how the screen tells "an admin turned this on" from "this has always been on". `dimensions` is what the action can spend, so the screen can offer one cap box per dimension. Nothing is seeded at boot: a deployment that has never opened this screen has no rows and behaves correctly.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Every registered action with its switch, its caps and the dimensions it can spend', content: { "application/json": { schema: { type: "object", properties: { actions: { type: "array", items: { type: "object", additionalProperties: true } }, worldChangingRisks: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.actions,
|
||||
)
|
||||
|
||||
eventsRouter.put(
|
||||
'/actions',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Enable or disable one action, and set its per-run caps'
|
||||
// #swagger.description = 'One action per request rather than the whole board, because the board is rendered from the registry and a whole-board write would have to decide what an action missing from the body means -- on a screen listing what is registered right now that is "a module booted between the read and the write", and writing a default over an admin stored choice is quiet data loss. A cap must name a dimension the action actually spends: a cap on a dimension it never names would be a number an operator believes is protecting them while it bounds nothing. Caps are copied into a run budget when the run is created, so moving a switch never changes what a run already in flight is allowed.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["actionId", "enabled"], properties: { actionId: { type: "string", example: "core.announce" }, enabled: { type: "boolean", example: true }, caps: { type: "object", additionalProperties: { type: "integer" }, example: { "uo.creatures": 30 } } } } } } } */
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The stored setting', content: { "application/json": { schema: { type: "object", properties: { action: { type: "object", additionalProperties: true } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'enabled is missing, or a cap names a dimension this action does not spend', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No module registers that action', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
controller.saveAction,
|
||||
)
|
||||
|
||||
eventsRouter.get(
|
||||
'/series',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
@@ -343,6 +381,20 @@ eventsRouter.get(
|
||||
controller.listVersions,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/verify',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
|
||||
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOrEditor,
|
||||
controller.verify,
|
||||
)
|
||||
|
||||
eventsRouter.post(
|
||||
'/:id/publish',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
|
||||
@@ -69,6 +69,7 @@ const gates = require('../events/gates')
|
||||
const spec = require('../events/spec')
|
||||
const registries = require('../modules/registries')
|
||||
const { dispatchStep } = require('../events/dispatch')
|
||||
const authorize = require('../events/authorize')
|
||||
const log = require('./logger')('event-runner')
|
||||
|
||||
const POLL_MS = Number(process.env.EVENT_POLL_MS) || 15_000
|
||||
@@ -141,14 +142,30 @@ function leaseFor(step, now) {
|
||||
// `skipped` is reserved for a step a human skipped from the run console (Phase
|
||||
// 3) — a status that meant both "nobody ran this" and "this failed and we moved
|
||||
// on" would make the run console's summary line unreadable.
|
||||
async function applyFailure(run, step, error) {
|
||||
await stepsDb.finish(step.id, 'failed', error)
|
||||
async function applyFailure(run, step, error, { status = 'failed', kind = 'step.status', extra = null } = {}) {
|
||||
// **`refused` shares this whole function with `failed`, and that is decision 3
|
||||
// of Phase 6** (org lead, 2026-09-03): a cap breach or a disabled action takes
|
||||
// the same disposition a terminal failure takes, so a `change` step's default
|
||||
// `pause` stops the run where it stands and an operator raises the cap, edits,
|
||||
// and resumes. What differs is the two words on the record — the STATUS the
|
||||
// step ends in and the KIND the log line carries — because "nothing here is
|
||||
// broken, this deployment does not permit that" is a different sentence from
|
||||
// "the shard did not answer", and an operator reading a stopped run at 2am
|
||||
// needs to tell them apart at a glance.
|
||||
await stepsDb.finish(step.id, status, error)
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'step.status',
|
||||
kind,
|
||||
phase: step.phase,
|
||||
detail: { to: 'failed', action: step.action_id, attempts: step.attempts + 1, onFailure: step.on_failure, error },
|
||||
detail: {
|
||||
to: status,
|
||||
action: step.action_id,
|
||||
attempts: step.attempts + 1,
|
||||
onFailure: step.on_failure,
|
||||
error,
|
||||
...(extra || {}),
|
||||
},
|
||||
})
|
||||
|
||||
// A run that lost a step is degraded whatever happens next. Health is not
|
||||
@@ -200,6 +217,50 @@ async function applyFailure(run, step, error) {
|
||||
async function drainStep(run, step, now, carry = {}) {
|
||||
if (!(await stepsDb.claim(step.id, OWNER, leaseFor(step, now), now))) return 'taken'
|
||||
|
||||
// ── The permission check, and it is the LAST thing before the dispatch ──
|
||||
//
|
||||
// §K's four layers behind one function (Phase 6). It sits after the claim, not
|
||||
// before it: the cap is held by a conditional UPDATE and two ticks that both
|
||||
// priced a step before either claimed it would both spend. It sits before the
|
||||
// dispatch because a refusal means the action does not happen — nothing is
|
||||
// sent, nothing is created, and the step never reaches the module at all.
|
||||
//
|
||||
// **`user` is null here, and that is the design rather than an omission.** The
|
||||
// role was checked when a human published the version and again when a human
|
||||
// or the scheduler started the run; a run in flight is deliberately not
|
||||
// re-gated against its starter's current role, because demoting an admin at
|
||||
// midnight should not silently strand every event they started. Cancel is the
|
||||
// control for a run that should stop.
|
||||
//
|
||||
// **A retry does not pay twice.** The spend happens on the first attempt only.
|
||||
// A retry re-dispatches the same idempotent operation against the same key, and
|
||||
// charging a cap for a flaky socket would exhaust a deployment's allowance
|
||||
// through unreliability rather than through effect. The corollary is that a
|
||||
// step which spent and then failed for good keeps its spend: the attempt may
|
||||
// have half-run, and a refund would be core asserting that it did not.
|
||||
const action = registries.eventAction(step.action_id)
|
||||
if (action) {
|
||||
const verdict = await authorize.mayInvoke({
|
||||
action,
|
||||
params: step.params || {},
|
||||
run,
|
||||
spend: step.attempts === 0,
|
||||
})
|
||||
if (!verdict.ok) {
|
||||
return applyFailure(run, step, verdict.reason, {
|
||||
status: 'refused',
|
||||
kind: 'step.refused',
|
||||
extra: {
|
||||
code: verdict.code,
|
||||
dimension: verdict.dimension,
|
||||
requested: verdict.requested,
|
||||
cap: verdict.cap,
|
||||
consumed: verdict.consumed,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dispatchStep(step, { run })
|
||||
|
||||
if (result.actionVersionDrift) {
|
||||
@@ -572,6 +633,7 @@ async function processRun(run, now = new Date()) {
|
||||
async function expandSchedules(now) {
|
||||
const definitions = await definitionsDb.findSchedulable()
|
||||
let created = 0
|
||||
const heldUnverified = new Set()
|
||||
|
||||
for (const definition of definitions) {
|
||||
const schedule = definition.version_spec?.schedule
|
||||
@@ -610,7 +672,27 @@ async function expandSchedules(now) {
|
||||
{ scope: '', scheduledFor: occurrence.at, source: 'schedule' },
|
||||
null,
|
||||
)
|
||||
if (!result.ok || !result.created) continue
|
||||
if (!result.ok) {
|
||||
if (result.code === 'unverified') {
|
||||
// §K's gate, and it must not be silent. There is no run row to hang
|
||||
// a diagnostic line on — that is the point, nothing was created — so
|
||||
// it is said once per definition per tick rather than once per
|
||||
// occurrence, and the admin surface says it where the author is
|
||||
// looking: a `ready` definition carries `versionVerified: false` and
|
||||
// the editor shows the one button that clears it.
|
||||
if (!heldUnverified.has(definition.id)) {
|
||||
heldUnverified.add(definition.id)
|
||||
log.warn('scheduled occurrences held: the published version has never been verified', {
|
||||
definition: definition.id,
|
||||
title: definition.title,
|
||||
version: definition.current_version_id,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!result.created) continue
|
||||
created += 1
|
||||
if (occurrence.adjusted) {
|
||||
// Why the clock reads oddly, recorded where an operator will look for
|
||||
|
||||
Reference in New Issue
Block a user