docs(events): Phase 6 as built — enablement, caps and mayInvoke
EVENTS.md §D — the two tables move from "arrive with a later phase" to built, with the two facts that are not obvious from the column list: a missing settings row is the risk-class default rather than "disabled", and a NULL cap is uncapped and still a row, so a MISSING row keeps its one meaning. event_versions gains verified_at/verified_by, and the note on why two mutable columns do not break an immutable table. §E — a new subsection, "Enablement and caps, in front of every dispatch": where the check sits and why, the retry that does not pay twice (and the failure that gets no refund), the partial spend that is unwound, the budget seeded from every phase at creation, the tightest cap, and the one place a demoted user's access deliberately is not re-checked. §K — the amendment. "Nothing above notify" becomes "nothing that changes the world", with the reason: read literally it shipped core.wait disabled. The same line is the role floor. Plus mayInvoke as built — why user and run may be null, why the cap check can write, why the role floor is in the model rather than in middleware, and the example-pricing stand-in until registerEventBudgets lands. §I, §L, § API surface and § Observability — the switchboard and the dry run marked built; the two refusal rows and the held-occurrence row; the three new log kinds and the run's fourth derived field; why the whole-plan cost check is the one finding no other path makes. EVENTS_PLAN.md — Phase 6 complete in the shape Phases 0–5 use: the four org-lead decisions, the two derived calls flagged for review, the three things the build settled, the one thing this phase deliberately cannot demonstrate with a module (registerEventActions is Phase 7's seam), the two defects the live walk found, the verified numbers and the walk itself. Checked by hand — docs has no CI: every anchor resolves, and every route, column, env var and file path named here exists on website#XXX. Diff is 151/23 and 118/2 with no CRLF artefact, verified against the real content diff rather than --numstat alone. 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:
@@ -584,12 +584,123 @@ it saves, and it will not publish.*
|
||||
|
||||
### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`)
|
||||
|
||||
`event_action_settings` (one row per registered action, everything above `notify` disabled by default)
|
||||
> **Complete.** `edge` in `website` and `docs`. Two new tables — `event_action_settings` (the
|
||||
> switchboard) and `event_run_budget` (what a run has spent and the most it may) — plus two columns on
|
||||
> `event_versions`, `verified_at` and `verified_by`. `GET/PUT /admin/events/actions` and
|
||||
> `POST /admin/events/:id/verify` are the last routes this feature was missing bar `cleanup`, and
|
||||
> `events/authorize.js` is the one function §K asked for.
|
||||
>
|
||||
> **The four decisions the org lead settled (2026-09-03), all as recommended:**
|
||||
>
|
||||
> - **The default-off line falls between `inspect` and `change`, not between `notify` and `inspect`.**
|
||||
> §K's sentence read literally would have shipped `core.wait` — which is `risk: 'inspect'` —
|
||||
> disabled, so every published event that waits breaks on a fresh deployment until an admin finds
|
||||
> the switch. An `inspect` action reads state and writes nothing, so a deployment gains no risk by
|
||||
> having it on. §K is amended, and **the same line is the role floor**: `change` and `irreversible`
|
||||
> are the steps only an admin may author.
|
||||
> - **The tightest cap wins.** `event_action_settings.caps` is per action while `event_run_budget` is
|
||||
> one row per dimension, so two actions spending `uo.creatures` have to agree on one number, and the
|
||||
> number a safety limit should settle on is the smaller. It is pinned into the run at creation with
|
||||
> the action it came from, so the console can say whose switch set it.
|
||||
> - **A refusal follows the step's `on_failure`, and takes health to `degraded`.** A cap breach or a
|
||||
> disabled action is not an outage, so it is `refused` rather than `failed` and gets its own log
|
||||
> kind — but it takes the same **disposition** a failure takes, which means a `change` step's
|
||||
> default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes.
|
||||
> Skipping and carrying on would leave a completed run with a hole in it, which nobody reads a log
|
||||
> for.
|
||||
> - **The verify gate is enforced for SCHEDULED starts only.** A version must have passed its dry run
|
||||
> before the scheduler will materialise an occurrence of it unattended; a human pressing *Start now*
|
||||
> is not gated, because that human **is** the review the gate exists to require.
|
||||
>
|
||||
> **Two things derived rather than decided, and both flagged for review.** A dry run **fails** — it
|
||||
> does not merely warn — when a version names a disabled action or over-spends a cap, which is exactly
|
||||
> what the scheduled-start gate is for; and the unattended path **does not re-check the starter's
|
||||
> role**, because demoting an admin at midnight must not silently strand every event they started
|
||||
> (cancel is the control for a run that should stop).
|
||||
>
|
||||
> **Three things the build settled:**
|
||||
>
|
||||
> - **The check is after the claim and before the dispatch, and it can write.** After, because the cap
|
||||
> is held by a conditional `UPDATE` and two ticks that both priced a step before either claimed it
|
||||
> would both spend. Before, because a refusal means the action does not happen at all. And `spend`
|
||||
> is a *parameter* of `mayInvoke` rather than a second function, because checking and then spending
|
||||
> would be two statements with a race between them — the exact race the conditional increment
|
||||
> removes.
|
||||
> - **A retry does not pay the cap twice, and a failure does not get a refund.** The spend happens on
|
||||
> the first attempt only: charging a cap for a flaky socket would exhaust a deployment's allowance
|
||||
> through unreliability rather than through effect. The corollary is deliberate — a step that spent
|
||||
> and then failed for good keeps its spend, because the attempt may have half-run and a refund would
|
||||
> be core asserting that it did not. (A *partial* spend across dimensions IS unwound: a step refused
|
||||
> on its second dimension gives the first one back, because a step that did not run must not have
|
||||
> spent anything.)
|
||||
> - **`NULL` cap is uncapped and still a row; a MISSING row is a refusal.** Keeping the two distinct
|
||||
> is what lets the meter count a dimension nothing bounds while a step spending something its own
|
||||
> run's version never priced still fails closed.
|
||||
>
|
||||
> **The one thing this phase deliberately cannot demonstrate with a module.** `registerEventActions`
|
||||
> is not on the module-facing API yet — that seam is **Phase 7's** — and core's own three actions
|
||||
> declare no `cost`. So the cap machinery ships with no live consumer, exactly as the plan intends
|
||||
> ("ships: a system that cannot be made to do an unbounded amount of anything"). It is proved instead
|
||||
> by unit tests, by a real-MariaDB concurrency test, and by a **rig action added to core for the walk
|
||||
> and reverted before commit**.
|
||||
>
|
||||
> **Two defects the live walk found, and neither could have been caught anywhere else:**
|
||||
>
|
||||
> 1. **The run console route dropped the budget.** `runs.detail()` returned it and the client read it,
|
||||
> but `getRun` builds its response field by field and never listed it — so the meter would have
|
||||
> rendered empty for ever, with the model test and the client both correct in isolation. **A
|
||||
> hand-built response shape is a place a new field silently does not arrive**, and only a request
|
||||
> through the whole stack says so.
|
||||
> 2. **The role refusal did not agree with itself.** `"Spawn creatures" change the world` — the
|
||||
> sentence always used the plural verb over a list that is almost always one long, so the case an
|
||||
> editor actually sees read as a broken sentence rather than as a rule.
|
||||
>
|
||||
> **Verified:** `npm test` — **1921 tests, 1847 pass, 73 skipped, 1 fail**, that one still the
|
||||
> pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch and
|
||||
> watching it fail unchanged; `edge` before: 1810/1746/63/1). **+111 tests**, of which the +10 skipped
|
||||
> are the new SQL cases skipping without a database. `eventAuthorize.test.js` (27) is `mayInvoke`'s
|
||||
> four layers and the cap arithmetic; `eventVerify.test.js` (12) the dry run, including the
|
||||
> whole-plan total; `eventsRoles.test.js` (31) is **the 403 walk**, driving the real router with every
|
||||
> handler replaced so it measures gates rather than handlers; plus 12 in `eventRunner.test.js`, 19 in
|
||||
> `eventsAdmin.test.js`, and **10 in `eventRunnerSql.test.js` against a real MariaDB** (49/49 with a
|
||||
> database, skipped without) — among them the plan's own criterion, two concurrent spends against one
|
||||
> cap. Client: **362 pass**, 1 new; the client builds. Three routes added, **none moved**.
|
||||
>
|
||||
> **The live walk, on the local review stack.** The gate announced itself before anything was
|
||||
> authored: a leftover Phase 4 definition began logging *"scheduled occurrences held: the published
|
||||
> version has never been verified"* on the first tick after boot.
|
||||
>
|
||||
> - **The board arrived with the right posture.** `core.wait` (`inspect`) enabled, `core.announce` and
|
||||
> `core.cue` enabled, the rig's `change` action disabled, and every row marked `configured: false` —
|
||||
> a deployment that has never opened the screen, behaving correctly with no rows at all.
|
||||
> - **The dimension was discovered by pricing the declared example**, live, and the cap editor
|
||||
> offered exactly it. A cap naming a dimension the action does not spend was refused by name.
|
||||
> - **The dry run caught the whole-plan total.** Three steps of 15 under a cap of 30: each legal
|
||||
> alone, `45 of "core.creatures" across all its steps` refused together, before anything was
|
||||
> scheduled.
|
||||
> - **The dry run found a real defect in a leftover definition** — an announce leg `"site"` that no
|
||||
> module registers — and, being a failure, **recorded nothing**, so the schedule stayed held. Fixing
|
||||
> and re-verifying lifted the hold on the same tick and the occurrence materialised.
|
||||
> - **An editor was refused a world-changing step** and allowed an announcing one; an admin saved the
|
||||
> same body.
|
||||
> - **The cap bit at dispatch.** Two 15s spent 30 of 30 and the third came back `refused: asks for 15
|
||||
> of "core.creatures"; 30 of 30 is already spent this run`, health `degraded`, run `completed`
|
||||
> under `on_failure: skip`. With `pause`, a disabled action stopped the run at step 0 and left the
|
||||
> step after it `pending`.
|
||||
> - **The 403 walk held live** as well as in the harness: `GET` and `PUT /actions` answered 403 to the
|
||||
> editor and the moderator alike, and 200 to the admin.
|
||||
>
|
||||
> **One thing this PR fixes in passing:** the committed OpenAPI spec still carried the pre-Phase-5
|
||||
> catalog description — the annotation had been edited without a regenerate. `npm run swagger` here
|
||||
> picks it up, which is why the spec diff has two deletions in it that are not this phase's.
|
||||
|
||||
`event_action_settings` (one row per action an admin has an opinion about — everything that CHANGES
|
||||
THE WORLD disabled by default; the line moved off `notify` when this phase built it, see above)
|
||||
and `event_run_budget` with the conditional increment:
|
||||
|
||||
```sql
|
||||
UPDATE event_run_budget SET consumed = consumed + ?
|
||||
WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
|
||||
WHERE run_id = ? AND dimension = ? AND (cap IS NULL OR consumed + ? <= cap)
|
||||
```
|
||||
|
||||
A breach is `refused` — the step does not run, does not retry, and is surfaced to the author with the
|
||||
@@ -606,6 +717,11 @@ while cancelling and aborting a run in flight are `admin` + `moderator`**. Start
|
||||
deliberately not the same gate. (`advance` took that same gate in Phase 5, which is when it first
|
||||
named a state an operator could be in.)
|
||||
|
||||
*As built, P3 had already put every route gate in place, so what P6 actually added here is the two
|
||||
new routes' own gates and the one gate that could not live in route middleware at all: the per-STEP
|
||||
role floor. The route is `admin, editor` and stays that way — **which of the two you have to be
|
||||
depends on what is in the body**, so it is checked in the model, at save as well as at publish.*
|
||||
|
||||
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
|
||||
anything.
|
||||
**Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends;
|
||||
|
||||
Reference in New Issue
Block a user