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
75 lines
2.8 KiB
JavaScript
75 lines
2.8 KiB
JavaScript
// ── event_versions — SQL only ──────────────────────────────────────────────
|
|
//
|
|
// EVENTS.md §D. Immutable: there is an insert and there are reads, and there is
|
|
// deliberately no update and no delete. A run pins a version, and that pin is
|
|
// what makes the run reproducible and an audit answerable after the definition
|
|
// has been edited underneath it.
|
|
|
|
const { query } = require('../../utils/db')
|
|
const { parseJson } = require('./eventJson')
|
|
|
|
const hydrate = (row) => row && { ...row, spec: parseJson(row.spec, null) }
|
|
|
|
const listForDefinition = async (definitionId) =>
|
|
(
|
|
await query(
|
|
`SELECT v.id, v.definition_id, v.version, v.published_at, v.published_by, u.username AS published_by_username
|
|
FROM event_versions v
|
|
LEFT JOIN users u ON u.id = v.published_by
|
|
WHERE v.definition_id = ?
|
|
ORDER BY v.version DESC`,
|
|
[definitionId],
|
|
)
|
|
).map((row) => row)
|
|
|
|
const getById = async (id) => {
|
|
const [row] = await query('SELECT * FROM event_versions WHERE id = ?', [id])
|
|
return hydrate(row)
|
|
}
|
|
|
|
/**
|
|
* The next version number for a definition.
|
|
*
|
|
* Read separately and then INSERTed, which is a read-then-write — and it is safe
|
|
* only because `UNIQUE (definition_id, version)` is behind it. Two publishes
|
|
* racing for version 4 is one 1062 the caller reports, not two rows called 4.
|
|
* The unique index is the mechanism; this query is the ergonomics.
|
|
*/
|
|
const nextVersion = async (definitionId) => {
|
|
const [row] = await query(
|
|
'SELECT COALESCE(MAX(version), 0) + 1 AS next FROM event_versions WHERE definition_id = ?',
|
|
[definitionId],
|
|
)
|
|
return Number(row?.next || 1)
|
|
}
|
|
|
|
const insert = async (definitionId, version, spec, userId) => {
|
|
const result = await query(
|
|
'INSERT INTO event_versions (definition_id, version, spec, published_by) VALUES (?, ?, ?, ?)',
|
|
[definitionId, version, JSON.stringify(spec), userId],
|
|
)
|
|
return result.insertId
|
|
}
|
|
|
|
/**
|
|
* Record that a dry run passed against this version (Phase 6).
|
|
*
|
|
* A version is immutable in every respect that describes the EVENT — its spec,
|
|
* its number, who published it. These two columns describe something that
|
|
* happened to it afterwards, which is why they can be written at all: a pass is
|
|
* a fact about a review, not a change to the plan reviewed.
|
|
*
|
|
* Deliberately not idempotent-checked: verifying twice stamps the second one, and
|
|
* the later reviewer is the more useful answer to "who last looked at this
|
|
* before it ran unattended".
|
|
*/
|
|
const markVerified = async (id, userId, at = new Date()) => {
|
|
const result = await query(
|
|
'UPDATE event_versions SET verified_at = ?, verified_by = ? WHERE id = ?',
|
|
[at, userId, id],
|
|
)
|
|
return (result.affectedRows || 0) > 0
|
|
}
|
|
|
|
module.exports = { listForDefinition, getById, nextVersion, insert, markVerified }
|