Merge pull request 'docs(events): Phase 6 as built — enablement, caps and mayInvoke' (#214) from docs/events-phase-6 into edge
Reviewed-on: #214
This commit is contained in:
@@ -409,11 +409,11 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. |
|
| `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. |
|
||||||
| `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. |
|
| `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. |
|
||||||
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. |
|
| `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`, and (Phase 6) `verified_at` / `verified_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. The two `verified_*` columns are the exception that proves the immutability: they describe something that happened **to** the version afterwards — a review — rather than changing the plan reviewed, which is also why a pass recorded against one stays true for ever ([§K](#k--security-model)). |
|
||||||
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
|
| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. |
|
||||||
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
|
| `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. |
|
||||||
| `event_action_settings` | `action_id`, `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per registered action, all disabled above `notify` until an admin says otherwise. Not a grant table — nobody is named, because the role check already answered who. |
|
| `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. |
|
||||||
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap` — so the cap holds under concurrent steps without a transaction. |
|
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. |
|
||||||
| `event_run_resources` | `run_id`, `step_id`, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among non-reverted rows | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. |
|
| `event_run_resources` | `run_id`, `step_id`, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among non-reverted rows | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. |
|
||||||
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
|
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. |
|
||||||
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
|
| `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. |
|
||||||
@@ -456,7 +456,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th
|
|||||||
| `event_runs.status` | `scheduled` · `starting` · `running` · `paused` · `ending` · `completed` · `cancelled` · `failed` · `missed` | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal for a schedule that passed its grace window while the process was down — **never a late silent start**. |
|
| `event_runs.status` | `scheduled` · `starting` · `running` · `paused` · `ending` · `completed` · `cancelled` · `failed` · `missed` | `starting` and `ending` exist for the reason `sending` does in the outbox: they are what a claim sets. `missed` is terminal for a schedule that passed its grace window while the process was down — **never a late silent start**. |
|
||||||
| `event_runs.health` | `ok` · `degraded` · `stalled` | Separate from status, because a run can be genuinely *running and degraded* — announcements landing, world writes parked — and one column cannot say both. This is `installed_modules`' split. |
|
| `event_runs.health` | `ok` · `degraded` · `stalled` | Separate from status, because a run can be genuinely *running and degraded* — announcements landing, world writes parked — and one column cannot say both. This is `installed_modules`' split. |
|
||||||
| `event_runs.cleanup_status` | `not_required` · `pending` · `complete` · `incomplete` | Also separate: a run **reaches `completed` with `cleanup_status = 'incomplete'`** rather than being held open, and stays on the admin screen until a human resolves it. |
|
| `event_runs.cleanup_status` | `not_required` · `pending` · `complete` · `incomplete` | Also separate: a run **reaches `completed` with `cleanup_status = 'incomplete'`** rather than being held open, and stays on the admin screen until a human resolves it. |
|
||||||
| `event_run_steps.status` | `pending` · `running` · `done` · `failed` · `skipped` · `refused` · `cancelled` | `refused` is the cap breach, and it is deliberately not `failed` — nothing is wrong with the system. **A step waiting on a human is `running` with a NULL lease** (Phase 2, below). |
|
| `event_run_steps.status` | `pending` · `running` · `done` · `failed` · `skipped` · `refused` · `cancelled` | `refused` is a cap breach **or a disabled action**, and it is deliberately not `failed` — nothing is wrong with the system, the deployment simply does not permit what the author asked for. It takes the same **disposition** a failure takes (`on_failure`, and health `degraded`), and says a different thing: a distinct status and a distinct log kind, so an operator reading a stopped run at two in the morning can tell "raise the cap" from "the shard did not answer" at a glance. **A step waiting on a human is `running` with a NULL lease** (Phase 2, below). |
|
||||||
|
|
||||||
### The scheduler
|
### The scheduler
|
||||||
|
|
||||||
@@ -616,6 +616,49 @@ publish, because a version runs are pinned to must not wait on a trigger that ca
|
|||||||
predicate is carried through unvalidated rather than dropped — there is no declaration to check it
|
predicate is carried through unvalidated rather than dropped — there is no declaration to check it
|
||||||
against, and deleting it would silently discard what the author wrote.
|
against, and deleting it would silently discard what the author wrote.
|
||||||
|
|
||||||
|
### Enablement and caps, in front of every dispatch
|
||||||
|
|
||||||
|
Phase 6. The runner gained one thing: before it asks a module to do anything, it asks
|
||||||
|
`mayInvoke()` whether it may — [§K](#k--security-model)'s four layers behind one function. Three
|
||||||
|
properties of *where* that check sits are load-bearing.
|
||||||
|
|
||||||
|
**It is after the claim and before the dispatch.** 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: nothing is sent, nothing is created, and
|
||||||
|
the step never reaches the module.
|
||||||
|
|
||||||
|
**A retry does not pay twice.** The spend happens on the first attempt only. A retry re-dispatches
|
||||||
|
the same idempotent operation against the same key, and 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 step costing two dimensions can be refused on the second**, and the first is given back. The
|
||||||
|
spends are separate statements — the atomicity that matters is per dimension, and a transaction
|
||||||
|
spanning three of them would serialise three unrelated counters to buy nothing — so the partial spend
|
||||||
|
is real and has to be unwound. A step that did not run must not have spent anything.
|
||||||
|
|
||||||
|
**The run's budget is seeded from every phase's steps at creation**, not phase by phase as the run
|
||||||
|
enters them. The version is pinned and immutable, so all of its steps are knowable at once, and a
|
||||||
|
budget that grew as phases were entered would let a phase-1 step spend what a phase-3 step was going
|
||||||
|
to need — the opposite of a per-run bound. **The caps are copied**, so an admin moving a switch
|
||||||
|
tomorrow does not change what a run already in flight is allowed, and the console's meter afterwards
|
||||||
|
answers *"what was this run allowed"* rather than *"what is allowed now"*.
|
||||||
|
|
||||||
|
**Two actions spending one dimension resolve to the tightest cap** (org lead, 2026-09-03), pinned at
|
||||||
|
creation with the action it came from. `event_action_settings.caps` is per action while
|
||||||
|
`event_run_budget` is one row per dimension, so the two have to agree on one number, and the number a
|
||||||
|
safety limit should settle on is the smaller. An action that declines to cap a dimension never raises
|
||||||
|
a ceiling another one set — `null` is uncapped and does not win a minimum — because otherwise adding
|
||||||
|
a second verb to an event would silently remove the bound on the first.
|
||||||
|
|
||||||
|
**The runner never re-checks the role of whoever started the run.** `mayInvoke` takes a null user on
|
||||||
|
this path and skips the role layer entirely. The role was checked when a human published the version
|
||||||
|
and again when a human or the scheduler started the run; re-checking at three in the morning would
|
||||||
|
mean an unrelated personnel change silently strands every event an admin started.
|
||||||
|
[§K](#k--security-model)'s *"a demoted user loses access at once"* is about reaching a route, and it
|
||||||
|
still holds exactly there. Cancel is the control for a run that should stop.
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
|
|
||||||
| Contention | Protection | Not an in-process mutex, because |
|
| Contention | Protection | Not an in-process mutex, because |
|
||||||
@@ -623,7 +666,7 @@ against, and deleting it would silently discard what the author wrote.
|
|||||||
| Two instances materialising one occurrence | `UNIQUE (definition_id, scope, scheduled_for)` + `INSERT IGNORE` | the database is the only thing both instances share. |
|
| Two instances materialising one occurrence | `UNIQUE (definition_id, scope, scheduled_for)` + `INSERT IGNORE` | the database is the only thing both instances share. |
|
||||||
| Two instances starting one run | CAS `scheduled → starting`; the instance told `affectedRows = 1` owns it | verbatim the outbox claim chosen over `SKIP LOCKED`. |
|
| Two instances starting one run | CAS `scheduled → starting`; the instance told `affectedRows = 1` owns it | verbatim the outbox claim chosen over `SKIP LOCKED`. |
|
||||||
| Two instances running one step | CAS `pending → running` plus a lease | the lease covers the instance that died mid-step. |
|
| Two instances running one step | CAS `pending → running` plus a lease | the lease covers the instance that died mid-step. |
|
||||||
| Two steps spending one cap | Conditional increment with the cap in the `WHERE` | a read-then-write would let two steps each see 28/30 and both spend. |
|
| Two steps spending one cap | Conditional increment with the cap in the `WHERE` | a read-then-write would let two steps each see 28/30 and both spend. **The guard must stay in the `WHERE`**: MariaDB evaluates a `SET` list left to right with the values already assigned, which is how Phase 5's gate closed a firing early, so nothing in this statement may read `consumed` after writing it. Proved against a real server, not a stub. |
|
||||||
| An orphaned claim | Reclaim on lease expiry, **without resetting `attempts`** | Engagement Phase 14's exact defect: a reclaim that reset state made `MAX_ATTEMPTS` unreachable and the row cycled forever, never terminal and therefore never retention-eligible. |
|
| An orphaned claim | Reclaim on lease expiry, **without resetting `attempts`** | Engagement Phase 14's exact defect: a reclaim that reset state made `MAX_ATTEMPTS` unreachable and the row cycled forever, never terminal and therefore never retention-eligible. |
|
||||||
| Two events overlapping | `concurrency_key` as a **template rendered from the run's params** — e.g. `invasion:{region}` | a flat definition-id key would wrongly stop the same definition running on two Rust servers, or in two regions, at once. |
|
| Two events overlapping | `concurrency_key` as a **template rendered from the run's params** — e.g. `invasion:{region}` | a flat definition-id key would wrongly stop the same definition running on two Rust servers, or in two regions, at once. |
|
||||||
|
|
||||||
@@ -953,8 +996,8 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th
|
|||||||
| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. |
|
| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. |
|
||||||
| **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. |
|
| **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. |
|
||||||
| **Step editor** — pick an action, fill its params | **The condition builder, exactly**: core serves a catalog, the module declared the schema, core renders a form it does not understand | Option sources; a live cap meter. |
|
| **Step editor** — pick an action, fill its params | **The condition builder, exactly**: core serves a catalog, the module declared the schema, core renders a form it does not understand | Option sources; a live cap meter. |
|
||||||
| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. |
|
| **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. **Built in Phase 6**, `admin` only. Each row says whether it is showing a stored decision or a risk-class default, because "an admin turned this on" and "this has always been on" are different facts and only one is a decision somebody made. A cap may only name a dimension the action actually spends — a cap on one it never names would be a number an operator believes is protecting them while it bounds nothing. |
|
||||||
| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. |
|
| **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. **Built through Phase 6**, caps included: a meter per dimension rather than a sentence, because unlike a gate a cap is two numbers and a name and needs no grammar rendered to be read. Shown on finished runs too — *"how much did last night's invasion actually spawn"* is the same question asked the morning after. |
|
||||||
|
|
||||||
**A calendar entry is one of two things, and Phase 4 draws the difference rather than stating it.**
|
**A calendar entry is one of two things, and Phase 4 draws the difference rather than stating it.**
|
||||||
A **run** is a real `event_runs` row: it has a status, a pinned version and a console, and somebody can
|
A **run** is a real `event_runs` row: it has a status, a pinned version and a console, and somebody can
|
||||||
@@ -995,7 +1038,7 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie
|
|||||||
|
|
||||||
### Four affordances worth building in from the start
|
### Four affordances worth building in from the start
|
||||||
|
|
||||||
- **Dry run.** Materialise the steps, dispatch each with `verify: true`, report what *would* happen
|
- **Dry run.** *(Built, Phase 6.)* Materialise the steps, dispatch each with `verify: true`, report what *would* happen
|
||||||
and what it *would* cost against the caps. The project's own idiom, and what turns an unattended
|
and what it *would* cost against the caps. The project's own idiom, and what turns an unattended
|
||||||
world write from alarming into routine.
|
world write from alarming into routine.
|
||||||
- **Rehearsal.** Run for real with announcements ceilinged to `staff`. Free from the ceilings lattice
|
- **Rehearsal.** Run for real with announcements ceilinged to `staff`. Free from the ceilings lattice
|
||||||
@@ -1097,13 +1140,32 @@ live game world on a schedule.
|
|||||||
|
|
||||||
1. **Declaration.** A module says a verb exists, what it costs, and what risk class it is. This is
|
1. **Declaration.** A module says a verb exists, what it costs, and what risk class it is. This is
|
||||||
code the operator installed; it is not a permission.
|
code the operator installed; it is not a permission.
|
||||||
2. **Enablement.** An admin enables an action for this deployment and sets its per-run caps. **Nothing
|
2. **Enablement.** An admin enables an action for this deployment and sets its per-run caps.
|
||||||
above `notify` is enabled by default** — the posture a seeded engagement rule already takes,
|
**Nothing that CHANGES THE WORLD is enabled by default** — `change` and `irreversible` arrive off,
|
||||||
arriving `enabled = 0` because installing a module must never start doing things.
|
`notify` and `inspect` arrive on — the posture a seeded engagement rule already takes, arriving
|
||||||
|
`enabled = 0` because installing a module must never start doing things.
|
||||||
|
|
||||||
|
> **This line said "nothing above `notify`" until Phase 6 built it** (org lead, 2026-09-03). Read
|
||||||
|
> literally it shipped `core.wait` — which is `risk: 'inspect'` — disabled, so every published
|
||||||
|
> event that waits would break on a fresh deployment until an admin found the switch. An `inspect`
|
||||||
|
> action reads state and writes nothing, so a deployment gains no risk by having it on. **The same
|
||||||
|
> line is the role floor below**, and it moved with it.
|
||||||
|
|
||||||
|
A missing row is not "disabled": it is *the default for the action's risk class*, computed rather
|
||||||
|
than stored. Nothing is seeded at boot, and that is structural rather than tidy — registration runs
|
||||||
|
under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API §2.2), so a boot-time
|
||||||
|
seed of one row per registered action would be exactly the database write those two forbid.
|
||||||
3. **Invocation.** `requireRole`, exactly as every other admin capability is gated, plus the cap check
|
3. **Invocation.** `requireRole`, exactly as every other admin capability is gated, plus the cap check
|
||||||
and — for anything reaching the game — the shard's own `AdminWriteEnabled` and
|
and — for anything reaching the game — the shard's own `AdminWriteEnabled` and
|
||||||
`AdminAccessFloor`. Admin routes are already re-validated against the database on every request, so
|
`AdminAccessFloor`. Admin routes are already re-validated against the database on every request, so
|
||||||
a demoted user loses access immediately.
|
a demoted user loses access immediately. **A run already in flight is the one place that
|
||||||
|
re-validation deliberately does not reach** — see [§E](#e--runtime-model).
|
||||||
|
|
||||||
|
The shard's own switches are named as a layer and **not duplicated in core**. They live on the
|
||||||
|
shard host, outside the website's reach entirely, and a second copy of that decision in core would
|
||||||
|
be a copy that could disagree with the shard about whether the shard is accepting writes. A module
|
||||||
|
honours them when it translates an action into a sidecar command (P9). Naming the layer is what
|
||||||
|
stops it being re-implemented.
|
||||||
|
|
||||||
### Which role, per surface
|
### Which role, per surface
|
||||||
|
|
||||||
@@ -1117,7 +1179,7 @@ decided in [§N2](#n--decisions): starting a run is `admin` only, while stopping
|
|||||||
| Authoring and editing a draft | `admin`, `editor` |
|
| Authoring and editing a draft | `admin`, `editor` |
|
||||||
| Publishing a version, **starting a run** | `admin` only |
|
| Publishing a version, **starting a run** | `admin` only |
|
||||||
| **Live control of a run in flight** — cancel, abort | `admin`, `moderator` |
|
| **Live control of a run in flight** — cancel, abort | `admin`, `moderator` |
|
||||||
| Any step whose action is above `notify`, and the action switchboard | `admin` only |
|
| Any step whose action **changes the world** (`change`, `irreversible`), and the action switchboard | `admin` only |
|
||||||
|
|
||||||
> **Why start and stop are gated differently.** Starting commits the deployment to everything the
|
> **Why start and stop are gated differently.** Starting commits the deployment to everything the
|
||||||
> definition contains, unattended, up to every cap it declares — it wants the narrowest gate there is.
|
> definition contains, unattended, up to every cap it declares — it wants the narrowest gate there is.
|
||||||
@@ -1131,6 +1193,41 @@ decided in [§N2](#n--decisions): starting a run is `admin` only, while stopping
|
|||||||
> rather than a redesign. If a deployment ever wants named coordinators with their own budgets, that
|
> rather than a redesign. If a deployment ever wants named coordinators with their own budgets, that
|
||||||
> is one function learning to consult a second table, and nothing else in this document changes.
|
> is one function learning to consult a second table, and nothing else in this document changes.
|
||||||
|
|
||||||
|
**As built** (Phase 6, `events/authorize.js`). It answers an envelope rather than a boolean — every
|
||||||
|
refusal carries a `code` a caller branches on and a `reason` a human reads, written in one place so
|
||||||
|
the dry run, the editor, the run console and the log all say the same sentence about the same fact.
|
||||||
|
Four callers: authoring a step, publishing, the dry run, and the runner's own unattended dispatch.
|
||||||
|
Three things about it are not obvious from the signature:
|
||||||
|
|
||||||
|
- **`user` may be null**, and that is the unattended runner rather than the system escalating. The
|
||||||
|
role layer is skipped, for the reason given in [§E](#e--runtime-model).
|
||||||
|
- **`run` may be null**, and the cap question changes when it is. With a run, the question is "does
|
||||||
|
this fit in what is left"; without one — the editor, the dry run — it is "could this cost *ever*
|
||||||
|
fit", which is an authoring error answerable before anything is scheduled.
|
||||||
|
- **The cap check can WRITE.** The cap is held by a conditional `UPDATE` whose `WHERE` carries the
|
||||||
|
guard, so checking and then spending would be two statements with a race between them — the exact
|
||||||
|
race the conditional increment removes. `spend: true` is therefore a parameter rather than a second
|
||||||
|
function: one decision procedure, one set of layers, and the authoritative check is the one that
|
||||||
|
also commits.
|
||||||
|
|
||||||
|
**The role floor is checked in the model, not in route middleware**, and it is the one gate that
|
||||||
|
could not live there: the route is `admin, editor` and stays that way, while *which of the two you
|
||||||
|
have to be* depends on what is in the body. Checked at save as well as at publish — the difference
|
||||||
|
between telling an editor now and telling them after they have written twelve steps.
|
||||||
|
|
||||||
|
**A module's `cost()` is called by core and nowhere else.** It is declared as a function of params
|
||||||
|
([§F](#f--the-module-contract)) and called with the params the step actually carries, so the number
|
||||||
|
core enforces is the number the module said. A `cost()` that throws, or answers anything but a flat
|
||||||
|
object of non-negative finite numbers, makes the action **unpriceable and therefore refused** — never
|
||||||
|
free. An action whose own accounting is broken is not an action whose consumption is zero.
|
||||||
|
|
||||||
|
> **Until [§F](#f--the-module-contract)'s `registerEventBudgets` lands in Phase 7, core discovers an
|
||||||
|
> action's dimensions by pricing its declared `example` values.** That is a use every param's required
|
||||||
|
> `example` already has, it costs nothing, and it is honest about its limit: a `cost()` returning
|
||||||
|
> different dimension *keys* for different params under-reports, which costs an operator a cap box on
|
||||||
|
> the switchboard and costs a run nothing at all — a run's budget is seeded from the params its steps
|
||||||
|
> were actually authored with, never from examples.
|
||||||
|
|
||||||
### Can arbitrary module commands be injected through the browser?
|
### Can arbitrary module commands be injected through the browser?
|
||||||
|
|
||||||
**No, and the design must keep it that way.** The browser posts an action *id* and a params object;
|
**No, and the design must keep it that way.** The browser posts an action *id* and a params object;
|
||||||
@@ -1144,10 +1241,10 @@ controller stamps it from the session.
|
|||||||
| Bounded by | How |
|
| Bounded by | How |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Per-run caps | The strongest control, and the reason to keep quotas after dropping delegation. A stolen admin session cannot exceed 30 creatures per run because the cap is enforced in a conditional `UPDATE`, not in a role check — and a role check is exactly what a stolen session has already passed. |
|
| Per-run caps | The strongest control, and the reason to keep quotas after dropping delegation. A stolen admin session cannot exceed 30 creatures per run because the cap is enforced in a conditional `UPDATE`, not in a role check — and a role check is exactly what a stolen session has already passed. |
|
||||||
| Enablement defaulting off | Nothing above `notify` is invocable until an admin turned it on deliberately. |
|
| Enablement defaulting off | Nothing that changes the world is invocable until an admin turned it on deliberately. |
|
||||||
| The shard's own switches | `AdminWriteEnabled` is off by default in `Bridge.cfg`, lives on the shard host outside the website's reach, and `AdminAccessFloor` refuses any target at or above CoOwner. |
|
| The shard's own switches | `AdminWriteEnabled` is off by default in `Bridge.cfg`, lives on the shard host outside the website's reach, and `AdminAccessFloor` refuses any target at or above CoOwner. |
|
||||||
| The ledger | Every world write is recorded with its run, step and actor, and every ledgered resource has a declared revert. Damage is attributable and, by construction, undoable. |
|
| The ledger | Every world write is recorded with its run, step and actor, and every ledgered resource has a declared revert. Damage is attributable and, by construction, undoable. |
|
||||||
| Dry run before anything unattended | A scheduled definition that has never been verified is the case worth refusing to start; verification is cheap and it is the last point a human sees the plan. |
|
| Dry run before anything unattended | **Built and enforced in Phase 6, for SCHEDULED starts only** (org lead, 2026-09-03). A version records who verified it and when; the scheduler refuses to materialise an occurrence of one that never passed, and says so once per definition per tick rather than silently. A human pressing *Start now* is not gated, because that human **is** the review the gate exists to require. A failing dry run records nothing, so it cannot unlock anything. |
|
||||||
|
|
||||||
> **Stated plainly.** The module boundary is **not** a security boundary — MODULE_SYSTEM §2.2 says so
|
> **Stated plainly.** The module boundary is **not** a security boundary — MODULE_SYSTEM §2.2 says so
|
||||||
> explicitly, and a module runs in the same Node process with full access. None of the above defends
|
> explicitly, and a module runs in the same Node process with full access. None of the above defends
|
||||||
@@ -1171,7 +1268,9 @@ controller stamps it from the session.
|
|||||||
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
|
||||||
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
|
||||||
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
|
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
|
||||||
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. |
|
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. It then takes the step's own `on_failure`, so a `change` step's default `pause` stops the run where it stands and an operator raises the cap, edits, and resumes (org lead, 2026-09-03). Health goes `degraded`. |
|
||||||
|
| **A step names an action this deployment has disabled** | The same `refused`, in the same words as a cap breach, with the reason naming the action by its label. It is not an authoring mistake — the plan was legal when it was written — so the switchboard is the fix, and a disabled action reached by a run in flight is exactly the case the dry run catches beforehand. Phase 6. |
|
||||||
|
| **A scheduled occurrence of a version nobody dry-ran** | Not materialised at all, and logged once per definition per tick rather than once per occurrence — there is no run row to hang a diagnostic on, which is the point. The definition itself carries the fact, so the editor says so where its author is already looking. §K, Phase 6. |
|
||||||
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
|
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
|
||||||
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
|
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
|
||||||
| **A human skips a step** | `skipped`, which is what that status is reserved for. Legal for a `pending` step and for a parked cue; refused for a step with a live lease, and unnecessary for a `failed` one — `nextOpenStep` already passes over it, so resuming the run carries the phase past it. |
|
| **A human skips a step** | `skipped`, which is what that status is reserved for. Legal for a `pending` step and for a parked cue; refused for a step with a live lease, and unnecessary for a `failed` one — `nextOpenStep` already passes over it, so resuming the run carries the phase past it. |
|
||||||
@@ -1224,7 +1323,7 @@ no URL moved.
|
|||||||
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
|
| `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` |
|
||||||
| `DELETE /admin/events/:id` | admin | archive — never a hard delete while runs reference it |
|
| `DELETE /admin/events/:id` | admin | archive — never a hard delete while runs reference it |
|
||||||
| `GET /admin/events/:id/versions` | staff | version history |
|
| `GET /admin/events/:id/versions` | staff | version history |
|
||||||
| `POST /admin/events/:id/verify` | admin, editor | **dry run** — dispatch with `verify: true`, report cost against the caps |
|
| `POST /admin/events/:id/verify` | admin, editor | **dry run** (Phase 6) — dispatch every step with `verify: true`, report the findings and the whole-plan cost against the caps. Verifies the PUBLISHED version of a `ready` definition and the working spec of a draft; a pass against a version is recorded and is what unlocks scheduled starts (§K) |
|
||||||
| `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` |
|
| `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` |
|
||||||
| `GET /admin/events/runs` | staff | run history across definitions |
|
| `GET /admin/events/runs` | staff | run history across definitions |
|
||||||
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
|
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
|
||||||
@@ -1240,7 +1339,7 @@ no URL moved.
|
|||||||
| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen |
|
| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen |
|
||||||
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
|
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
|
||||||
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
|
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
|
||||||
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps |
|
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps (Phase 6). `admin` on the read as well as the write; the PUT takes one action at a time |
|
||||||
| `GET /public/events` | — | the calendar: upcoming and live, by category, scope and series |
|
| `GET /public/events` | — | the calendar: upcoming and live, by category, scope and series |
|
||||||
| `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results |
|
| `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results |
|
||||||
| `GET /public/events/series/:slug` | — | the arc |
|
| `GET /public/events/series/:slug` | — | the arc |
|
||||||
@@ -1281,9 +1380,37 @@ runner ticks every fifteen seconds, so a console rendered thirty seconds ago des
|
|||||||
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
|
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
|
||||||
interrupt. A refusal is a `409` naming the status the run is actually in.
|
interrupt. A refusal is a `409` naming the status the run is actually in.
|
||||||
|
|
||||||
Still absent, and still for reasons rather than for scope: `cleanup` and cancel's `cleanup` flag (no
|
**Phase 6 added the last three, and the list of absent routes is now one item long.** `GET/PUT
|
||||||
resource ledger until Phase 8), `verify` and `GET/PUT /admin/events/actions` (no caps to price
|
/admin/events/actions` is the switchboard, `admin` in **both** directions — §K puts it in the same
|
||||||
against and no switchboard to serve until Phase 6). `advance` left this list in Phase 5.
|
row as the world-changing actions it governs, and knowing exactly what a deployment permits is not a
|
||||||
|
staff-wide read. The PUT takes **one action per request** rather than the whole board: the board is
|
||||||
|
rendered from the registry, so a whole-board write would have to decide what an action *missing* from
|
||||||
|
the body means, and on a screen listing what registered on this boot that is "a module booted between
|
||||||
|
the read and the write" — answering it by writing a default over an admin's stored choice is quiet
|
||||||
|
data loss.
|
||||||
|
|
||||||
|
`POST /admin/events/:id/verify` is the dry run, at `admin, editor`. **What it verifies follows the
|
||||||
|
definition's state**, and the answer 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. Only a pass
|
||||||
|
against a version is recorded — a version is immutable, so a pass on one stays true, while a pass on
|
||||||
|
a draft would be a claim about a spec that changes under the author's hands. **Findings come back
|
||||||
|
with a 200**: the request succeeded, the plan has problems, and answering 4xx would make "this event
|
||||||
|
asks for 45 and you allow 30" indistinguishable from "you sent a bad event id".
|
||||||
|
|
||||||
|
Still absent, and still for a reason rather than for scope: `cleanup` and cancel's `cleanup` flag,
|
||||||
|
because there is no resource ledger until Phase 8. `advance` left this list in Phase 5.
|
||||||
|
|
||||||
|
> **The whole-plan cost check is the one thing no other path makes.** Every per-step check the dry run
|
||||||
|
> performs is also made at save or at dispatch; the TOTAL is not. Three steps each spawning 15 under a
|
||||||
|
> cap of 30 pass every individual check and breach the cap on the third — at two in the morning,
|
||||||
|
> unattended, with the world half-changed. Adding the costs up across a whole version is what only a
|
||||||
|
> look at the plan as a whole can do, and it is why a dry run is worth more than the sum of its step
|
||||||
|
> checks.
|
||||||
|
|
||||||
|
A run's detail gained a fourth derived field: **`budget`**, one row per dimension with what the run
|
||||||
|
has spent, the cap it was given, and *which action's switch set that cap* — so a number on the meter
|
||||||
|
traces back to something an operator can change rather than a figure they have to go looking for.
|
||||||
|
|
||||||
A third derived field joined `waitingSteps` and `parked` there: a run's detail carries **`gates`**,
|
A third derived field joined `waitingSteps` and `parked` there: a run's detail carries **`gates`**,
|
||||||
one entry per phase that authored an advance condition, already rendered. Every gate the run has
|
one entry per phase that authored an advance condition, already rendered. Every gate the run has
|
||||||
@@ -1324,7 +1451,8 @@ Phase 3 — "The Boss" has not started.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Definition edits, publishes and manual transitions, with actor | `event_run_log` + `activity_log` |
|
| Definition edits, publishes and manual transitions, with actor | `event_run_log` + `activity_log` |
|
||||||
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
|
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
|
||||||
| Cap draws and refusals, per dimension | `event_run_budget` + log |
|
| Cap draws and refusals, per dimension | `event_run_budget` + `event_run_log`, kinds `run.budget` (the caps a run was seeded with, and which switch set each) and `step.refused` (the dimension, what was asked, what was already spent). **`step.refused` is its own kind rather than a `step.status`**, so a run that stopped because the deployment does not permit something does not read as a run that broke. Phase 6 |
|
||||||
|
| A version passing its dry run, on the runs already pinned to it | `event_run_log`, kind `version.verified` — the line that explains why a held occurrence is suddenly going to start |
|
||||||
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
|
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
|
||||||
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
|
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
|
||||||
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
|
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
|
||||||
@@ -1359,7 +1487,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
|
|||||||
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
|
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
|
||||||
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
|
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
|
||||||
| **P5** ✓ | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
|
| **P5** ✓ | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
|
||||||
| **P6** | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
| **P6** ✓ | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
|
||||||
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
|
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
|
||||||
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
|
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |
|
||||||
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
|
| **P9** | UO wave 1 — the actions that need no protocol change | `module-uo` `docs` |
|
||||||
|
|||||||
@@ -584,12 +584,123 @@ it saves, and it will not publish.*
|
|||||||
|
|
||||||
### Phase 6 — Enablement, caps and `mayInvoke` (`website` + `docs`)
|
### 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:
|
and `event_run_budget` with the conditional increment:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UPDATE event_run_budget SET consumed = consumed + ?
|
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
|
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
|
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.)
|
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
|
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
|
||||||
anything.
|
anything.
|
||||||
**Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends;
|
**Verify:** `npm test`, including two concurrent steps against one cap proving neither over-spends;
|
||||||
|
|||||||
Reference in New Issue
Block a user