feat(events): enablement, per-run caps and mayInvoke (Phase 6)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s

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:
2026-09-03 05:50:58 -05:00
parent 4ac917c3a3
commit 4077c4e79e
31 changed files with 3890 additions and 24 deletions

View File

@@ -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' }}>