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:
@@ -11,6 +11,7 @@ import {
|
||||
blankStep,
|
||||
blankPhase,
|
||||
describeLogLine,
|
||||
logKindWord,
|
||||
runStatusWord,
|
||||
describeSchedule,
|
||||
scheduleFormFrom,
|
||||
@@ -557,3 +558,47 @@ test('the log renders Phase 5\'s three kinds, including the near miss', () => {
|
||||
/loot advanced on its deadline after 600s/,
|
||||
)
|
||||
})
|
||||
|
||||
test("the log renders Phase 6's three kinds, and a refusal does not read as a failure", () => {
|
||||
// The distinction the whole kind exists for. An operator scanning a stopped run
|
||||
// has to be able to see that nothing is broken — the deployment simply does not
|
||||
// permit what the author asked for — and the answer differs by cause: a switch
|
||||
// for "not enabled", a number for "over the cap".
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: 'asks for 12 of "uo.creatures"; 28 of 30 is already spent this run' },
|
||||
}),
|
||||
/uo\.creature\.spawn refused: asks for 12 of "uo\.creatures"; 28 of 30 is already spent this run/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'step.refused',
|
||||
detail: { action: 'uo.creature.spawn', error: '"Spawn creatures" is not enabled on this deployment' },
|
||||
}),
|
||||
/refused: "Spawn creatures" is not enabled/,
|
||||
)
|
||||
assert.equal(logKindWord('step.refused'), 'Refused')
|
||||
|
||||
// The caps a run was seeded with, and which switch set each — so a number on
|
||||
// the meter can be traced back to something an operator can change.
|
||||
assert.match(
|
||||
describeLogLine({
|
||||
kind: 'run.budget',
|
||||
detail: { dimensions: [{ dimension: 'uo.creatures', cap: 30, from: 'uo.creature.spawn' }] },
|
||||
}),
|
||||
/uo\.creatures capped at 30 \(uo\.creature\.spawn\)/,
|
||||
)
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'run.budget', detail: { dimensions: [{ dimension: 'uo.gate.minutes', cap: null, from: null }] } }),
|
||||
/uo\.gate\.minutes capped at nothing/,
|
||||
)
|
||||
// A run with no capped dimension at all still gets a sentence rather than an
|
||||
// empty line, because an empty log entry reads as a bug.
|
||||
assert.match(describeLogLine({ kind: 'run.budget', detail: { dimensions: [] } }), /no caps apply to this run/)
|
||||
|
||||
assert.match(
|
||||
describeLogLine({ kind: 'version.verified', detail: { versionId: 4, version: 2, by: 1 } }),
|
||||
/Version 2 passed its dry run — scheduled occurrences may start/,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user