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:
@@ -144,6 +144,10 @@ export default function EventRun() {
|
||||
const [steps, setSteps] = useState([])
|
||||
const [counts, setCounts] = useState({})
|
||||
const [gates, setGates] = useState([])
|
||||
// The caps this run was given and what it has spent of them (Phase 6). Copied
|
||||
// into the run when it was created, so this is what THIS run is allowed rather
|
||||
// than what the switchboard says today.
|
||||
const [budget, setBudget] = useState([])
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -163,6 +167,7 @@ export default function EventRun() {
|
||||
setSteps(detail.steps || [])
|
||||
setCounts(detail.counts || {})
|
||||
setGates(detail.gates || [])
|
||||
setBudget(detail.budget || [])
|
||||
setLines(log.log || [])
|
||||
}, [runId])
|
||||
|
||||
@@ -342,6 +347,59 @@ export default function EventRun() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── What this run is allowed, and what it has spent ──
|
||||
A meter rather than a sentence: a cap is two numbers and a name, and
|
||||
unlike a gate it needs no grammar rendered to be read. It is shown for
|
||||
every run that has a budget at all, finished ones included — "how much
|
||||
did last night's invasion actually spawn" is the same question asked
|
||||
the morning after. */}
|
||||
{budget.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14 }}>
|
||||
<h3 className="sans" style={{ margin: '0 0 6px', fontSize: '0.92rem' }}>Caps</h3>
|
||||
<table className="sans" style={{ fontSize: '0.82rem', borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
{budget.map((b) => {
|
||||
const spent = b.cap === null ? 0 : Math.min(b.consumed / b.cap, 1)
|
||||
const full = b.cap !== null && b.consumed >= b.cap
|
||||
return (
|
||||
<tr key={b.dimension}>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ fontSize: '0.78rem' }}>{b.dimension}</code>
|
||||
</td>
|
||||
<td style={{ padding: '3px 12px 3px 0', whiteSpace: 'nowrap', color: full ? '#d9c184' : undefined }}>
|
||||
{b.cap === null ? `${b.consumed} spent` : `${b.consumed} of ${b.cap}`}
|
||||
</td>
|
||||
<td style={{ width: '100%', padding: '3px 0' }}>
|
||||
{b.cap === null ? (
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>no cap</span>
|
||||
) : (
|
||||
<span style={{ display: 'block', height: 6, background: 'var(--rule)', borderRadius: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
height: 6,
|
||||
width: `${Math.round(spent * 100)}%`,
|
||||
background: full ? '#d9c184' : '#8fc79a',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Which switch set the number, so an operator can trace a cap
|
||||
back to a thing they can change rather than wondering
|
||||
where 30 came from. */}
|
||||
<td className="dim" style={{ padding: '3px 0 3px 12px', whiteSpace: 'nowrap', fontSize: '0.78rem' }}>
|
||||
{b.from || ''}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Waiting on a person ── */}
|
||||
{parked.length > 0 && (
|
||||
<div className="panel-flat" style={{ padding: 14, marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
|
||||
|
||||
Reference in New Issue
Block a user