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:
252
client/src/routes/admin/views/EventActions.jsx
Normal file
252
client/src/routes/admin/views/EventActions.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// Admin → Events → Actions — the deployment's switchboard (EVENTS.md §K, Phase 6).
|
||||
//
|
||||
// **This screen is the whole of the permission model beyond the role.** A module
|
||||
// declaring `uo.creature.spawn` is code the operator installed; it is not a
|
||||
// permission they granted. Enablement is the grant, and the cap is how much of
|
||||
// it — so this is the one screen in the feature where an operator decides what
|
||||
// the deployment *can do at all*, rather than what it is going to do tonight.
|
||||
//
|
||||
// **Nothing above `notify` and `inspect` arrives enabled.** Installing a module
|
||||
// must never start doing things, which is the posture a seeded engagement rule
|
||||
// already takes by arriving `enabled = 0`. The line falls between `inspect` and
|
||||
// `change` (org lead, 2026-09-03): an `inspect` action reads state and writes
|
||||
// nothing, so a deployment gains no risk by having it on, and `core.wait` — which
|
||||
// is `inspect` — arriving off would break every published event that waits.
|
||||
//
|
||||
// **A row with no stored setting is not "off".** It is "the default for its risk
|
||||
// class", computed on the server by the same function the runner asks. The screen
|
||||
// says which it is looking at, because "an admin turned this on" and "this has
|
||||
// always been on" are different facts and only one of them is a decision.
|
||||
//
|
||||
// **Admin only in both directions**, including the read: §K puts the switchboard
|
||||
// in the same row as the world-changing actions it governs, and knowing exactly
|
||||
// what a deployment permits is not a staff-wide read.
|
||||
|
||||
const RISK_WORD = {
|
||||
notify: 'Tells people something',
|
||||
inspect: 'Reads the world',
|
||||
change: 'Changes the world',
|
||||
irreversible: 'Changes the world irreversibly',
|
||||
}
|
||||
|
||||
const RISK_COLOR = {
|
||||
notify: 'var(--muted)',
|
||||
inspect: 'var(--muted)',
|
||||
change: '#d9c184',
|
||||
irreversible: '#d98b84',
|
||||
}
|
||||
|
||||
const REVERSIBLE_WORD = {
|
||||
none: 'nothing to undo',
|
||||
self: 'undoes itself',
|
||||
ledger: 'undone from the ledger at teardown',
|
||||
override: 'restores a baseline',
|
||||
}
|
||||
|
||||
export default function EventActions() {
|
||||
const [actions, setActions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [problem, setProblem] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
// Cap edits are held here until they are saved, keyed `actionId:dimension`.
|
||||
// A cap is a number somebody types digit by digit, and writing on every
|
||||
// keystroke would put "3" in the database on the way to "30".
|
||||
const [drafts, setDrafts] = useState({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.admin.eventActions()
|
||||
setActions(data.actions || [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await load()
|
||||
if (alive) setError(null)
|
||||
} catch (err) {
|
||||
if (alive) setError(err.message)
|
||||
} finally {
|
||||
if (alive) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
/**
|
||||
* Write one action's row.
|
||||
*
|
||||
* The whole row goes every time — the switch and every cap — because the route
|
||||
* takes one action per request and a sparse write would have to decide what an
|
||||
* omitted cap means. Here it can only mean one thing, so it is sent.
|
||||
*/
|
||||
const save = async (action, { enabled = action.enabled, caps } = {}) => {
|
||||
setBusy(action.id)
|
||||
setProblem(null)
|
||||
setNotice(null)
|
||||
const nextCaps = caps !== undefined ? caps : capsOf(action)
|
||||
try {
|
||||
await api.admin.saveEventAction({ actionId: action.id, enabled, caps: nextCaps })
|
||||
await load()
|
||||
setDrafts((d) => {
|
||||
const next = { ...d }
|
||||
for (const dimension of action.dimensions) delete next[`${action.id}:${dimension}`]
|
||||
return next
|
||||
})
|
||||
setNotice(`Saved ${action.label}.`)
|
||||
} catch (err) {
|
||||
setProblem(err.message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
/** The caps this row would save: the drafts on top of what is stored. */
|
||||
const capsOf = (action) => {
|
||||
const out = {}
|
||||
for (const dimension of action.dimensions) {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
const value = draft !== undefined ? draft : action.caps[dimension]
|
||||
if (value === '' || value === undefined || value === null) continue
|
||||
out[dimension] = Number(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const capValue = (action, dimension) => {
|
||||
const draft = drafts[`${action.id}:${dimension}`]
|
||||
if (draft !== undefined) return draft
|
||||
const stored = action.caps[dimension]
|
||||
return stored === undefined || stored === null ? '' : String(stored)
|
||||
}
|
||||
|
||||
const dirty = (action) =>
|
||||
action.dimensions.some((d) => drafts[`${action.id}:${d}`] !== undefined)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message={error} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="sans" style={{ margin: '0 0 4px' }}>Event actions</h2>
|
||||
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: '62ch' }}>
|
||||
What this deployment permits an event to do, and how much of it per run. Anything that changes
|
||||
the world arrives switched off — installing a module declares a verb, it does not grant
|
||||
permission to use it. Caps are copied into a run when the run is created, so moving a switch
|
||||
never changes what a run already in flight is allowed.
|
||||
</p>
|
||||
|
||||
{problem && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #d98b84' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{problem}</span>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<div className="panel-flat" style={{ padding: 10, marginBottom: 12, borderLeft: '3px solid #8fc79a' }}>
|
||||
<span className="sans" style={{ fontSize: '0.85rem' }}>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.length === 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
No module registers an event action. Core always declares its own three, so an empty list
|
||||
here means the registry did not load.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="panel-flat"
|
||||
style={{
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderLeft: `3px solid ${action.enabled ? RISK_COLOR[action.risk] || 'var(--rule)' : 'var(--rule)'}`,
|
||||
opacity: action.enabled ? 1 : 0.75,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<strong className="sans" style={{ fontSize: '0.95rem' }}>{action.label}</strong>
|
||||
<code className="dim" style={{ fontSize: '0.78rem' }}>{action.id}</code>
|
||||
</div>
|
||||
{action.description && (
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.82rem' }}>{action.description}</p>
|
||||
)}
|
||||
<p className="sans dim" style={{ margin: '4px 0 0', fontSize: '0.78rem' }}>
|
||||
<span style={{ color: RISK_COLOR[action.risk] }}>{RISK_WORD[action.risk] || action.risk}</span>
|
||||
{' · '}
|
||||
{REVERSIBLE_WORD[action.reversible] || action.reversible}
|
||||
{/* Which of the two facts this is. A default is not a decision, and
|
||||
an operator auditing their own deployment needs to see the
|
||||
difference without reading the risk table in their head. */}
|
||||
{' · '}
|
||||
{action.configured
|
||||
? `set by ${action.updatedBy || 'an administrator'}`
|
||||
: 'never configured — showing the default for its risk class'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="sans" style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={action.enabled}
|
||||
disabled={busy === action.id}
|
||||
onChange={(e) => save(action, { enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{action.dimensions.length > 0 && (
|
||||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--rule)' }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 6px', fontSize: '0.78rem' }}>
|
||||
Per-run caps. Blank is uncapped — the run still counts what it spends, nothing bounds
|
||||
it. Where another enabled action spends the same thing, the tightest cap is the one a
|
||||
run gets.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
{action.dimensions.map((dimension) => (
|
||||
<label key={dimension} className="sans" style={{ fontSize: '0.8rem' }}>
|
||||
<span className="dim" style={{ display: 'block', marginBottom: 2 }}>{dimension}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
style={{ width: 110 }}
|
||||
value={capValue(action, dimension)}
|
||||
disabled={busy === action.id}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [`${action.id}:${dimension}`]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy === action.id || !dirty(action)}
|
||||
onClick={() => save(action)}
|
||||
>
|
||||
Save caps
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user