Mosaic one model, many lenses

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:

  1. Schedule next-run tracking — the scheduler part now exposes live next / last state for every declared schedule.
  2. Notification center — the notify aspect records every dispatch in an in-process ring buffer and exposes it on a /notifications page.
  3. CQRS replay scrubber / aggregate-state explorer — a /cqrs page replays the event log to any position and shows the aggregate state at that position.
  4. Vector-search playground — a /search page 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_rs computes the initial next for every enabled schedule (now + interval, the parsed at instant, or cron_next(...) for cron schedules) and stores last: null.
  • After each fire, the fire body records last = now and advances next (interval: now + interval; at: null; cron: recompute cron_next).
  • GET /api/schedules returns the live state map.
  • The web /schedules page is now live in every hydration mode: SSR reads SCHEDULE_STATE, the hydrated/client bundle fetches /api/schedules. The table shows declared timing/action/params plus next run and last 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 ok from the HTTP send result.
  • GET /api/notifications returns the ring newest-first as { "items": [...] }.
  • The web lens gains a conditional /notifications route + 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:

  1. locks the live store,
  2. builds a scratch Store::default(),
  3. replays the first N events through the existing crate::agg::replay_event primitive (the same path used by durable boot recovery),
  4. 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_k number 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:

  • csr continues to use gloo-net in [dependencies].
  • full / islands gain gloo-net under [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 a spawn_local future 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 via web_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: true deployments.
  • 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.