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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user