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

@@ -59,6 +59,10 @@ export default function EventEditor() {
const [problems, setProblems] = useState([])
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
// The dry run's answer (Phase 6). Cleared on every save and every publish,
// because a report is a statement about a spec and both of those change it —
// a stale green report beside an edited plan is worse than no report.
const [report, setReport] = useState(null)
const isAdmin = user?.role === 'admin'
// Reads here are staff-wide (§K), so a moderator reaches this screen legitimately
@@ -171,6 +175,9 @@ export default function EventEditor() {
setBusy(true)
setProblems([])
setNotice(null)
// A report describes a spec, and saving changes it. A green report left
// standing beside an edited plan is worse than no report at all.
setReport(null)
const built = payloadFromForm(form)
if (!built.ok) {
setProblems(built.errors)
@@ -200,6 +207,7 @@ export default function EventEditor() {
setBusy(true)
setProblems([])
setNotice(null)
setReport(null)
try {
const result = await api.admin.publishEvent(id)
setEvent(result.event)
@@ -218,6 +226,34 @@ export default function EventEditor() {
}
}
/**
* The dry run (Phase 6).
*
* `admin, editor` — it dispatches nothing. What it verifies follows the
* definition's state, and the server says which: a `ready` definition is
* checked against its PUBLISHED version, because that is the only thing that
* ever actually runs and it is that pass §K's gate is about; a draft is checked
* against the working spec the author is still holding.
*
* Findings arrive with a 200 — the request succeeded, the plan has problems —
* so they are rendered rather than thrown into the error box.
*/
const verify = async () => {
setBusy(true)
setProblems([])
setNotice(null)
setReport(null)
try {
const result = await api.admin.verifyEvent(id)
setReport(result)
if (result.recorded) setEvent(await api.admin.getEvent(id).then((r) => r.event))
} catch (err) {
setProblems(err.body?.errors || [err.message])
} finally {
setBusy(false)
}
}
const start = async () => {
setBusy(true)
setProblems([])
@@ -260,6 +296,14 @@ export default function EventEditor() {
{isNew ? 'Create draft' : 'Save'}
</button>
)}
{/* The dry run is admin+editor, deliberately wider than publish: an
author should be able to find out what their event would cost
before asking an admin to commit the deployment to it. */}
{!isNew && mayAuthor && (
<button type="button" className="pill" style={{ fontSize: '0.74rem' }} disabled={busy || archived} onClick={verify}>
Dry run
</button>
)}
{/* Publish and start are admin ONLY (§N2) and not the same gate as the
live controls: publishing commits a definition a schedule will later
start unattended. */}
@@ -291,6 +335,86 @@ export default function EventEditor() {
</p>
)}
{/* ── §K's gate, said where it can still be acted on ──
A published version that nobody has dry-run will not start on its
schedule. The alternative to saying so here is an operator finding out
on the Friday it did not run, so it is a banner rather than a log line —
and only for a definition that actually HAS a schedule to be held. */}
{!isNew && event?.state === 'ready' && !event?.currentVersionVerifiedAt && !archived && (
<div className="panel-flat" style={{ padding: '10px 14px', marginBottom: 14, borderLeft: '3px solid #d9c184' }}>
<p className="sans" style={{ margin: 0, fontSize: '0.84rem' }}>
<strong>This version has not been dry-run.</strong> Scheduled occurrences are held until it
is an event that starts while nobody is watching gets one review, and this is it.
Starting it by hand is unaffected.
</p>
</div>
)}
{report && (
<div
className="panel-flat"
style={{
padding: '10px 14px',
marginBottom: 14,
borderLeft: `3px solid ${report.report.ok ? '#8fc79a' : '#d98b84'}`,
}}
>
<p className="sans" style={{ margin: '0 0 6px', fontSize: '0.84rem' }}>
<strong>
{report.report.ok ? 'Dry run passed' : 'Dry run found problems'}
</strong>{' '}
<span className="dim">
· {report.report.steps} step{report.report.steps === 1 ? '' : 's'} checked against{' '}
{/* Which spec was checked. The two answer different questions, and a
report that did not say would be read as the other one. */}
{report.target === 'version' ? `published v${report.version}` : 'the working draft'}
{report.recorded && ' · recorded, so scheduled occurrences may now start'}
</span>
</p>
{report.report.findings.length > 0 && (
<ul className="sans" style={{ margin: '0 0 6px', paddingLeft: 18, fontSize: '0.82rem' }}>
{report.report.findings.map((f, i) => (
<li key={`${f.phase}-${f.seq}-${f.code}-${i}`} style={{ color: f.level === 'warning' ? '#d9c184' : undefined }}>
{f.phase !== null && (
<code className="dim" style={{ fontSize: '0.78rem' }}>
{f.phase} · step {f.seq + 1}
{f.actionId ? ` · ${f.actionId}` : ''}
</code>
)}
{f.phase !== null && ' — '}
{f.message}
</li>
))}
</ul>
)}
{/* The whole-plan cost, which is the finding no other path can make: a
step that fits on its own and does not fit alongside its siblings. */}
{report.report.cost.length > 0 && (
<table className="sans" style={{ fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<tbody>
{report.report.cost.map((c) => (
<tr key={c.dimension} style={{ color: c.over ? '#d98b84' : undefined }}>
<td style={{ paddingRight: 12 }}><code style={{ fontSize: '0.78rem' }}>{c.dimension}</code></td>
<td style={{ paddingRight: 12 }}>{c.total}</td>
<td className="dim">
{c.cap === null ? 'no cap' : `of ${c.cap} per run${c.from ? ` (${c.from})` : ''}`}
</td>
</tr>
))}
</tbody>
</table>
)}
{report.report.findings.length === 0 && report.report.cost.length === 0 && (
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
Nothing this event does costs a capped resource.
</p>
)}
</div>
)}
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: '#8fc79a' }}>{notice}</p>}
{problems.length > 0 && (