ADR 0030 — Aspect ops surfaces: schedule next-run, notification center, CQRS explorer, vector-search playground
This ADR lands the remaining U5 aspect surfaces from the EE sync ledger:
- Schedule next-run tracking — the scheduler part now exposes live
next/laststate for every declared schedule. - Notification center — the notify aspect records every dispatch in an
in-process ring buffer and exposes it on a
/notificationspage. - CQRS replay scrubber / aggregate-state explorer — a
/cqrspage replays the event log to any position and shows the aggregate state at that position. - Vector-search playground — a
/searchpage with collection, query,top_k, and source controls over the existing vectordb search API.
1. Schedule next-run tracking
Context
schedules: generated tokio tasks that fired commands/workflows, but the
/schedules page was a static, codegen-time table. There was no way to see
when a schedule would fire next or when it last fired.
Decision
When the scheduler part is active and at least one schedule is declared, the
generated server emits:
pub static SCHEDULE_STATE:
LazyLock<Mutex<BTreeMap<String, Value>>>
- At boot,
schedule_state_seed_rscomputes the initialnextfor every enabled schedule (now + interval, the parsedatinstant, orcron_next(...)for cron schedules) and storeslast: null. - After each fire, the fire body records
last = nowand advancesnext(interval:now + interval;at:null; cron: recomputecron_next). GET /api/schedulesreturns the live state map.- The web
/schedulespage is now live in every hydration mode: SSR readsSCHEDULE_STATE, the hydrated/client bundle fetches/api/schedules. The table shows declared timing/action/params plusnext runandlast fired.
The full web lens also spawns the scheduler (it serves the same store and API
as the app lens), so a ui: true deployment has the same schedule behavior as
the API-only binary.
2. Notification center
Context
notifies: generated a log line or a blocking webhook POST per matched event,
but there was no stored surface: no API, no UI, no way to inspect recent
dispatches or whether a webhook succeeded.
Decision
When at least one notify is declared, the generated server emits:
pub static NOTIFICATIONS:
LazyLock<Mutex<VecDeque<Value>>>
Each notify dispatch pushes one envelope (capped at 200 entries):
{
"notify": "<name>",
"channel": "log|webhook",
"aggregate": "<aggregate>",
"event": "<event>",
"aggregate_id": "<id>",
"body": "<interpolated body>",
"ok": true,
"ts": "<RFC3339>"
}
- Webhook dispatches record
okfrom the HTTP send result. GET /api/notificationsreturns the ring newest-first as{ "items": [...] }.- The web lens gains a conditional
/notificationsroute + nav entry with a live list (SSR reads the static; the client fetches the API).
3. CQRS replay scrubber / aggregate-state explorer
Context
The /events page shows the event log, and aggregate pages show current
instance state, but there was no way to inspect what the state was at an
earlier event position.
Decision
When at least one aggregate is declared, the generated server emits:
GET /api/events/state?up_to=N
The handler:
- locks the live store,
- builds a scratch
Store::default(), - replays the first
Nevents through the existingcrate::agg::replay_eventprimitive (the same path used by durable boot recovery), - returns
{ up_to, total, aggregates: { "<Aggregate>": { "<id>": <state> } } }.
The web lens gains a /cqrs page with a range input over the event count.
Moving the slider fetches /api/events/state?up_to=N and renders the
aggregate instances at that position. This is a dev/ops surface; it is
deliberately O(N) per request and capped by the live event log.
4. Vector-search playground
Context
The /knowledge page already had a minimal search box, but it fixed top_k
to 8, had no source control, and showed only a few hit columns.
Decision
When at least one vector_db is declared, the web lens gains a /search
route + nav entry:
- collection selector (one button per declared collection),
- query input,
top_knumber input,- optional source input,
- a hits table with score / id / locator / context.
The page POSTs to the existing POST /api/vectordb/{name}/search endpoint
({query, top_k, source?}), so no new search engine behavior is introduced.
The api_post helper now returns the parsed JSON body on success (previously
it discarded it), which also fixes the CSR knowledge-page search path.
5. Web-lens client runtime
The new pages are interactive in every hydration mode. To keep the generated web lens dependency-light:
csrcontinues to usegloo-netin[dependencies].full/islandsgaingloo-netunder[target.'cfg(target_arch = "wasm32")'.dependencies]only when an interactive surface is emitted.- The pages module emits a small client runtime:
spawn_client(f)— runs aspawn_localfuture on wasm and is a no-op during SSR,api/api_put/api_post— cfg-aware REST helpers,ev_value(&Event)— reads the current value of an input/select event target viaweb_sys::HtmlInputElement.
SSR branches keep their in-process store/static reads; the client bundle updates the same reactive signals after hydration.
Plan fix
check_platform_parts checked notify against the triggers presence flag
(parts.1) instead of the notifications flag (parts.2). This ADR fixes
the off-by-one so a notifies: declaration with notifications in
app.parts is accepted, while a missing part still fails closed.
Consequences
- The U5 aspect-surface learn-todo is complete: events, schedules, flags, dashboard, notification center, vector-search playground, and the CQRS replay scrubber / aggregate-state explorer are all codegen-derived.
- The full web lens now runs the scheduler, matching the app lens for
ui: truedeployments. - The CQRS explorer is a scratch-replay ops tool; it does not mutate the live store and does not change the durable log format.
- The notification ring is in-process and capped; a durable notification log would be a follow-up if it needs to survive restarts.
- The search playground is UI-only; it reuses the existing vectordb search API and does not change retrieval behavior.