a981b2b854cf0609cd55be9275613914503b87a2
18 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 743f1bc8b2 |
feat(events): a param that holds several values (MODULE_API 1.14.0)
Rust PLAN_REDESIGNS §12.3 (D344): an event action param may declare `multiple: true`, with an optional `max` (1-100, 20 when left out), on a `string` or `int` param. The permission reward steps take several permissions in one step through it. - registries: the flag and `max` are checked at registration and served in the catalog; refused on other types, on a duration, on a template's inputs, and with a list as the example. A param that does not say it reads back exactly as declared, without the keys. - spec: the value is a JSON array, each item the type's literal, duplicates dropped in order, at most `max`; a bare value is a list of one; `required` means at least one; an empty item is refused. - authorize: a multiple param's example is priced as a list of one, since cost() is handed the array. - editor: a multi-select. Chips above a grouped, filterable checklist for a source that answered; the typeahead for a searchable source; a tag box otherwise. Ticking past max is refused in words. A new step holds the example as a list of one. - run console: params read as `name: value`, a list joined by commas. - MODULE_API 1.13.0 -> 1.14.0 on both halves; engagement-triggers.json and the catalog's OpenAPI note regenerated. Additive only: Module-uo (`^1.10.0`) declares no multiple param. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY |
|||
| 3e243ce772 |
feat(events): the step editor reworked, and event templates (MODULE_API 1.13.0)
Rust PLAN_REDESIGNS §2 (U-3-U-6, F11; D192, D334), core half. Game-agnostic:
Module-uo's steps get the new controls and nothing else.
Contract (MODULE_API 1.12.0 -> 1.13.0, four optional additions):
- api.registerEventTemplates([{ id, label, description, inputs, build }]):
inputs are params (<= 8); core checks them, calls build() (10 s), validates
the result as a spec and answers an unsaved draft.
POST /admin/events/catalog/templates/:templateId (admin, editor).
- a param's duration: 'minutes' (int/float only); core.lease's minutes has it.
- a lease's example for its value.
- an option row's note (<= 300 chars), shown under the chosen field.
Editor:
- no type tags or bare asterisks; a required field says so in words once a
save has been tried with it empty (and the save stops before the round trip).
- boolean: toggle when required, [Not set] [Yes] [No] when optional (D334).
- a choice of two or three is a row of buttons (lease values, small sources).
- core.lease drawn as the chosen lease: picked by label; value by the lease's
type, range, values and example; target picker over target.source (F11);
minutes with the lease's longest hold.
- a timed step keeps its run open: a phase with no advance rule gets
after: <longest hold> when a timed step is added; a sentence under any phase
whose run can end sooner (U-6).
- Start asks "Publish and start" or "Start version N" when the working copy is
ahead of the published version (draftAhead on GET/PUT/publish; U-5).
- New event can start from a module's template.
- core's own examples name no game (§0.9).
Swagger, routes.manifest/guards and engagement-triggers.json regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
|
|||
| 9ddb59a326 |
feat(events): an event action's progress() on a live run's public page (MODULE_API 1.12.0)
An event action may declare an optional `progress(envelope)`: how its
step stands while the run is live. Core accepts two shapes and
interprets neither:
- a count: { label, left, of }, rendered "Bandits: 3 of 8 left";
- a percentage: { label, percent }, rendered "The Juggernaut: 62%".
How core asks:
- `events/progress.js` asks each step of a live run whose action has
one. It passes the step's own held ledger rows and the reader's
account id, so the module applies its own visibility.
- Each call has a 2 s deadline. A throw, a late answer or any other
shape is a line not shown, never an error on the page.
- One answer per run and reader is cached for 5 s.
- The public event's live occurrence carries the lines as `progress`.
The route takes an optional session for the reader.
- The page renders them under the phase label and re-reads them every
15 s while the event is live.
Core stays game-agnostic: no game's words are in it. Additive, so a
minor bump. Module-uo declares ^1.10.0, registers no action with
progress(), and its actions and pages are unchanged.
From RunicNPC stage 8 (D304, D305).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
|
|||
| 224e2b4dbc |
fix(events): settle a finished run whose last resource expired while it ran
Found walking Rust PLAN_FIXES step 2 (run 46 on the walk core): a zone expired while its run was still going, so expireResource left the run's cleanup status to its terminal path. When the run was cancelled the sweep never selected it, because runsNeedingCleanup joins on an unresolved row and it had none, so cleanup_status sat at `pending` for ever over an empty ledger. The sweep now settles finished runs that are `pending` with no unresolved row as `complete` (never over `incomplete`, which is a human's to clear). Checked against the walk database: the query returns run 46 and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY |
|||
| cc1f49af29 |
feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
A game that ends a resource at its own deadline — a Rust zone erased when its
time is up — could reach core only through ctx.events.reconcile(), which files
it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
call files it as the plan working (Rust PLAN_FIXES F14, D170, D183):
- event_run_resources.status gains `expired`, terminal like `reverted`: the
sweep never takes it back, live_marker releases the target, and it joins
neither HELD nor UNRESOLVED. The ENUM ALTER re-runs as a no-op on every boot
(checked on MariaDB 11.8 with the stored generated column depending on it).
- ctx.events.expired({ kind, ref }) marks the calling module's own pending,
confirmed or orphaned rows for that target expired and logs
`resource.expired`; a revert in flight is left to finish. The owner is bound
by the loader, like reconcile. A finished run whose last unresolved row this
was goes to cleanup `complete`, even from `incomplete`.
- The run console shows it green, "ended by the game on time".
Additions only, so minor. Module-uo (coreApi ^1.10.0) calls none of it and
reads no ledger status; the contract test now asserts the range still holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
|
|||
| 7d7840eb6b |
fix(events): midnight in an announcement is 12:00 am on the Node we ship
`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same request as a 12-hour clock, and it does not survive a Node upgrade: for a locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11, whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it is V8's ECMA-402 behaviour rather than locale data. The image ships node:20-alpine and CI runs Node 20, while a dev machine is newer -- which is how this rendered correctly in front of everyone who wrote it and wrongly for every real recipient. An event mail announcing a midnight start said "0:00 am" while the schedule editor beside it said "12:00 AM": one instant, two spellings, which is the exact contradiction the option was added to prevent. `hourCycle: 'h12'` is the request that means what was meant. `recurrence.js` already states the mirror-image rule for `h23`, and every other formatter in this repo and in module-uo uses `hourCycle`; there is no `hour12` left here. This is the one test that has been red on every events PR since #192, and the only one -- each of those runs reported `# fail 1`. Verified by running the suite under node:20-alpine, where the test fails without this change and 2152 tests pass with it; on Node 22+ it passes either way, so the test's comment now says that a green run on a dev machine is not evidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| e46842a28c |
fix(events): carry a module's own account of a successful step
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.
`module-uo` believed it, twice, since Phase 12b:
* `uo.item.grant` answers `{ granted, missed, why }`
* `uo.world.save` answers `{ started: true }`
The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.
Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.
## What this adds
`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.
**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.
* objects only — the column is JSON and the console renders keys, so a bare
string has nothing to render under, and core inventing a key would be core
interpreting it after all;
* 4KB of serialised JSON, dropped rather than truncated, because half a JSON
object is not a JSON object;
* unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
runner would make the log INSERT throw, inside the one write documented
never to;
* re-parsed rather than passed through, so core holds no live reference into
a module's object;
* **anything wrong with it is dropped and logged, never a failure.** A step
that did what it was asked must not be re-run because its module's
commentary was malformed: that is a world write repeated for a log line.
The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.
## The renderer, which is half the fix
`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.
uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true
**`module-uo` needs no change**: the code it already shipped starts working.
MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 1667e636bd |
feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 8453762e3b |
feat(events): the authoring UI proper (Phase 13)
Replaces the two raw JSON boxes Phase 3 shipped as explicit placeholders: a
step's params are a form rendered from the action's own declaration, and a
phase's advance condition is the engagement condition builder. Adds the live cap
meter, the searchable option source's first consumer, and a start dialog
carrying the three fields the route has taken since Phase 10.
One route: POST /admin/events/price, admin+editor. A module's cost() runs on the
server and only there, so a meter has nothing to add up until something asks --
and the dry run is the wrong thing to ask on a debounce twice over: it dispatches
every step through the module and a pass against a version is RECORDED, which is
the stamp K's unattended-start gate reads. This dispatches nothing and records
nothing, and takes the spec in the body because the plan being priced is unsaved
between keystrokes.
A form gives way to JSON on the condition builder's own rule: a value the editor
cannot round-trip is SHOWN rather than silently rewritten. Dropping a param the
action does not declare and flattening `A and (B or C)` are the same mistake.
Two defects fixed in already-merged code:
* Creating an event has been impossible since Phase 6. `events/new` was added
beside `events/:id` and binds no param, and React Router ranks a static
segment above a dynamic one whatever the order -- so the editor was handed no
id and fetched /admin/events/undefined. Worse, the failure was invisible:
`!form` is true for every failed load, so the error state sat behind a
spinner that never stopped.
* 12b's searchable sources had no consumer. The server half shipped and the
only UI that reads a source never sent a term, so the 6,707-entry spawner
list was picked from a 2,000-entry truncation with nothing saying so.
Server: 2113 tests, 2024 pass, 0 fail (89 DB-skipped). Client: 380 pass, 0 fail.
routes:manifest and swagger regenerated -- one route added, none moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 37f4623068 |
feat(events): targeted leases, value sets and searchable sources (Phase 12b)
The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.
Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.
Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.
Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.
`values` closes a `string` lease's set. `min`/`max` bound the numeric types and
nothing bounded `string`, so the only check on a string lease's value was the
game side's -- a refusal arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.
Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.
`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.
The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.
2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.
Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 7d3d6d5abd |
feat(events): the integrations — lifecycle triggers, participants, results (Phase 10)
`EVENTS_PLAN.md` Phase 10. Core registers its own `event.` triggers, records who took part, publishes a results table, and announces a post through the legs the news pipeline already uses. Events owns none of the delivery: a run says what happened and an operator's rule decides who is told, so email, the in-app inbox, push tickles, Discord and the town crier all arrive without anything in `events/` growing a second delivery path. **No route was added and nothing moved.** The whole surface is two more derived fields on a run — `participants` and `resultsPublishedAt` — and a zero-line `routes.manifest.json` diff proves it. Seven triggers: six at ceiling `authenticated` / audience `subscribers`, exactly where `news.post` sits, and `run.failed` at `admin` on both halves. Every one keys its cooldown on the RUN. Two rules seeded, both off, under a third one-shot key so a deployment that has already stamped the Team and news keys still gets them. **The phase's own defect was a promise nothing kept.** `EVENTS.md` §I says a rehearsal runs for real "with announcements ceilinged to `staff`" — but a ceiling is declared on the TRIGGER, and a rehearsal fires the same trigger as the real thing, so the moment this phase gave a run something to announce, rehearsing a published event would have mailed every subscriber. The emit envelope now takes an optional `ceiling` and the send-time G24 gate applies `meet(declared, emitted)`. It only narrows; two incomparable ceilings refuse every rule rather than resolving to either. `MODULE_API_VERSION` stays 1.10.0, amended in place — `main` declares 1.9.0, so 1.10.0 has not shipped and the org lead's 2026-09-03 rule applies for the third time. Three defects the live walk found, none visible to a unit test: 1. **A channel that reported success while reaching nobody.** The seeded `run.started` rule named `push`, because §8.5 and the plan both do. Push delivery joins `notification_subscriptions`, only ever written for an id the preferences screen offered push for — and it offers push only for a registered STREAM. So the tickle went nowhere every time while `pushChannel.deliver` answered "tickle published". `event.run.started` is now a stream as well as a trigger; the other six are not. 2. **A trigger's `description` reaches a recipient.** It is the structural projection's `intro` fallback, so `run.failed`'s line ending "Staff-facing." put those words in an administrator's own inbox item. 3. **`affectedRows` cannot tell an insert from an unchanged upsert.** The connector sends `CLIENT_FOUND_ROWS`, so a "was this new" flag would have counted every idempotent retried collect as a fresh participant. And one caught before it shipped: ranking with a session variable is wrong here, because `query()` takes a pool connection per call — the variable would be set on one connection and read on another. A window function needs no session state. ## Verification - `npm test --prefix server` — **1981 pass, 1 fail**, and that one (`botScore.test.js`) passes standalone at 18/18: a file-level flake under parallel load. Run with an empty `MODULES_DIR`, as CI does. - `npm test --prefix client` — 362 pass, 0 fail. `npm run build` green. - Zero-line `routes.manifest.json` / `routes.guards.json` diff. - A live walk on a real rig: MariaDB, the site with no module, mailpit. The mail arrived, headed with the event's title and its start time in the shard's own zone; the rehearsal fired the same trigger and produced zero outbox rows where the real run produced three; `run.failed` reached the administrator's inbox and no player's; `core.announce.post` queued a second job without touching the news pipeline's back-pointer or `announced_at`; and `rankRun` and the upsert were run against real MariaDB 11. ## One thing for a reviewer, out of scope and not fixed **Every `#swagger.description` in this repo is truncated in the generated spec.** swagger-autogen does not honour a backslash-escaped apostrophe, so a description is cut at the first `\'` — 175 of the 177 in `server/src/router/**`. It is pre-existing and repo-wide. Only the one annotation this phase edits is fixed here (a typographic apostrophe), because otherwise this phase's own addition to it would be dead text. The rest wants its own change. - [x] AI-assisted: Claude Code (Opus 5). Docs: RunicGateway/docs#TBD. Co-Authored-By: Claude <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
|||
| fdc118166c |
feat(events): the resource ledger, leases and cleanup (Phase 8)
Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN. One table, one core action, one route, one body field, and two members added to MODULE_API 1.10.0 in place. The safety property the whole world-write half depends on: core now remembers what a run changed in the world, and gives it back on every terminal path. Four decisions settled by the org lead on 2026-09-03, all as recommended: - A lease is acquired by a new CORE action, `core.lease`. Section F puts the duration bound and the two-events-one-target conflict check on core's side of the seam, and a lease verb per module would be both re-implemented once per module, advisory everywhere. - Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key. A spawn's ref does not exist until the module answers, so what core writes before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never comes it stands, and cleanup calls revert() with the key and no resources -- which is why section F's revert takes the key at all. - Cleanup is one sweep over the ledger, not synthetic step rows. The step-shaped version costs a second retry counter beside `revert_attempts`. - `reconcile` is declared here and TRIGGERED BY THE MODULE, through `ctx.events.reconcile()`. Core has no concept of the game being up, so it cannot decide when to ask; it asks once at its own boot. MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, so the whole module contract reaches an author as one version they read once. Verify - `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the pre-existing engagementManifest CRLF failure, in a file this branch does not touch (`edge` before: 1950/1876/73/1). +75 tests. - The unique key was proved against a REAL MariaDB, because nothing else can prove it: whether multiple NULLs collide in a unique index, whether a STORED generated column is recomputed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the server. eventRunnerSql.test.js gained 16 tests; 65 pass against the container. The real schema.sql was applied to a fresh database and to an existing one. - Client: 362 pass, and it builds. routes:manifest and swagger -- one route added, none moved. The live walk found three defects, and two of them are the phase's real finding Driven by a throwaway `rig` module in website/modules/, deleted before commit. 1. A lease was never given back at all. `core.lease` reserves its own ledger row, so it never went through the ledger's dirty-marking, so a run holding only a lease kept `cleanup_status = 'not_required'` and the cleanup leg -- which selected on `pending` -- never looked at it. 2. EVENT_REVERT_MAX_ATTEMPTS meant one attempt, not three. The first failing sweep moved the run to `incomplete`, which took it out of the leg's own scan for ever. The test covering the bound asserted `<= 3` and was satisfied by 1: a bound has two halves, and a test that only asserts the ceiling passes against a floor. 3. The first fix for (2) made the console lie. Spending every row's `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and the run page then rendered "3 attempts" beside resources nothing had ever tried. Found by opening the page. Both (1) and (2) are the same mistake: deriving "is there anything to do" from a summary column instead of from the rows. Neither was visible to a unit test, because a test that calls the sweep directly never asks what would have selected the run. The two properties that need the process to die were walked as the plan asks. With the module's perform() hanging, the placeholder existed while the dispatch was in flight and nothing was named; after taskkill and a restart the reclaim re-dispatched the same idempotency key, the retry re-used its own placeholder, and everything was given back. Then, with the module reporting one of two resources as no longer in force, the boot-time reconcile marked the other `orphaned` -- never `reverted`. This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| fd9fb50351 |
feat(events): open the event contract to modules (Phase 7)
MODULE_API 1.10.0. Four names forwarded on the module-facing `api` -- registerEventActions, registerEventBudgets, registerEventLeases and registerEventOptionSources -- one new route, and one rule made real: a `cost()` naming a dimension no module declared is refused. Only one of the four is new machinery. The action registry has staged core's three actions on every boot since Phase 1; what it never had was a way in, because loader.js builds its own `api` facade and had no method that delegated to it. So the registry a module now reaches is one that has been exercised on every boot for six phases. Four decisions, settled 2026-09-03, all as recommended: - Option sources are their own registration, modelled on registerAudiences, because a catalog has more than one consumer. - An undeclared dimension is refused -- at save, at the dry run and at dispatch -- with its own code, because the fix is a module's declaration and not a deployment's cap. - A lease is declared here and acquired by nothing; the ledger is Phase 8. - Core registers core.options.legs, so an announce leg is a dropdown rather than the free-text box whose typo Phase 6's walk caught mid-run. Proved with a throwaway module through the real loader, not with module-uo: eventModuleContract.test.js writes a module to a real directory and lets the loader scan it, covering all five envelope failure shapes, verify: true, the four id spaces and dormancy on uninstall. The live walk found the one defect nothing else could: the option-source loader wrote its "already asked?" guard inside a setState updater and read it on the next line, so the request was never made and the field sat on "Reading the list..." for ever. It is a useRef now. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL |
|||
| 4077c4e79e |
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 |
|||
| 9bc0bf5a3d |
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.
`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.
One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.
A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
|
|||
| 6e73660b52 |
feat(events): schedule, recurrence and the calendar (Phase 4)
The four closed recurrence shapes computed in the definition's own IANA zone, a fourteen-day materialisation horizon with projections beyond it, series as a managed thing, and the admin calendar that replaces the plugin this feature exists to replace. An event now happens on its own. No schema change: Phase 1 built every column this needed. - events/recurrence.js is the ONE place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree. No date library added — Node ships the tzdata one would vendor, behind Intl. - The runner's materialise leg is now two halves: expand, then sweep. The window starts at `now - grace`, so an occurrence nobody could have seen is never invented retroactively; the horizon is what makes the missed sweep mean anything for a recurrence. - Publishing is the schedule switch and archiving turns it off, and publishing re-pins every occurrence that has not started. - A projection is never drawn over an instant a run occupies, so a cancelled occurrence does not reappear as a forecast. 54 new tests, incl. the DST fixture set the plan asked for and three new statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail (pre-existing CRLF). Walked end to end on the local review stack. Docs: RunicGateway/docs#PENDING Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 2e964cfeee |
feat(events): the runner (Phase 2)
`utils/eventRunner.js`, the eighth poller, wired into server.js beside engagementWorker. Its tick reclaims stale leases, sweeps occurrences past their grace window into `missed`, advances each due run through its phases, and drains that phase's steps in `seq` order. The three core actions from Phase 1 get real bodies, so a published event started from the existing run route now announces, waits and completes on its own. No routes are added: a runner has no surface, and the live controls stay Phase 3's. Four things the org lead settled (2026-09-02): a parked step is `running` with a NULL lease; `await: 'human'` and `holdFor` are ordinary success-envelope members rather than special cases keyed on an action id; a run whose concurrency key is held stays `scheduled` and lets its grace window decide; and `n` in §L's `retry(n)` is a runner constant. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 8e03497eb3 |
feat(events): schema, CRUD and the core action registry (Phase 1)
EVENTS_PLAN.md Phase 1. Six of the nine core tables — the ones that do not
depend on the module contract — plus definitions CRUD, publish, archive, and
the action registry with core as its first registrant.
**Nothing dispatches.** There is no runner until Phase 2, so a run row is
created and stays `scheduled`. That is this phase's correct answer and the
surface renders it verbatim rather than hiding it.
Schema (`db/schema.sql`, append-only):
event_series, event_definitions, event_versions, event_runs,
event_run_steps, event_run_log. The four that need a writer —
event_action_settings, event_run_budget, event_run_resources,
event_run_participants — arrive with the phases that give them one.
Registry (`modules/registries.js` + `config/coreEventActions.js`):
registerEventActions staging and commit, with its own id namespace, the
closed risk and reversibility sets, revert() required iff and only iff
reversible: 'ledger', a bounded budgetMs and a param shape whose every
entry needs a type and an example. perform/revert/cost are stripped from
everything the catalog serves. Core declares core.announce, core.wait and
core.cue through the same staging area a module will use.
It is reachable ONLY by registerCore(): loader.js builds its own api facade
and has no method that delegates here, so no module can call it and
MODULE_API_VERSION is untouched. Phase 7 adds the facade and the bump.
Surface (13 routes under /api/v1/admin/events):
Reads staff-wide; publish, archive and run creation admin-only from this
phase per EVENTS.md §N2, even though the switchboard they will consult does
not exist yet — a button that is admin-only later and open now is a gate
nobody notices was missing. The live run controls and `verify` are absent
rather than stubbed, because nothing is in flight yet.
Four things the build settled, all recorded in docs:
- event_definitions gained a `spec` column. A draft's working copy cannot
be an event_versions row: that table is immutable and a run pins one.
- The spec validator must accept its own output. It added `actionVersion`
and `dormant` and then refused them as unknown keys, which would have made
the second save of any definition — and publish's re-validation —
impossible. A test caught it; both are now accepted and recomputed.
- A param's `example` is required, optional params included, matching
registerEventTriggers. It is the authoring form's placeholder.
- Two routes the §API-surface table did not name: GET /admin/events/:id and
GET /admin/events/series.
Core's three perform() bodies answer { ok: false, retry: false } rather than
{ ok: true }: `ok: true` on an action that did nothing is a recorded world
change that did not occur, which is the exact mistake §F's failure default
exists to prevent.
`conditions.checkLiteral` is exported and reused for step-param type checking
— one switch over the six types, so "is this a datetime" has one answer.
Verified: 44 new tests, whole server suite, `npm run check:modules`, routes
manifest and swagger regenerated (the manifest diff is +13 routes, zero moved).
Docs: RunicGateway/docs#209
Co-Authored-By: Claude <noreply@anthropic.com>
|