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 d of action.dimensions) delete next[`${action.id}:${d.id}`] 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 { id: 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.id}`] !== undefined) if (loading) return if (error) return return (

Event actions

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.

{problem && (
{problem}
)} {notice && (
{notice}
)} {actions.length === 0 && (

No module registers an event action. Core always declares its own three, so an empty list here means the registry did not load.

)} {actions.map((action) => (
{action.label} {action.id}
{action.description && (

{action.description}

)}

{RISK_WORD[action.risk] || action.risk} {' · '} {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'}

{action.dimensions.length > 0 && (

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.

{action.dimensions.map((d) => ( ))}
)}
))}
) }