|
|
5 months ago | |
|---|---|---|
| .. | ||
| svelte | 5 months ago | |
| test | 5 months ago | |
| README.md | 5 months ago | |
| active-bus.svelte.ts | 5 months ago | |
| engine-bus.ts | 5 months ago | |
| index.ts | 5 months ago | |
| silent-bus.ts | 5 months ago | |
| types.ts | 5 months ago | |
README.md
bus
Status 2026-05-14: layer split into
libs/bus(pure contracts) andarts/bus(engine implementation) is done. Modules consume the bus through interfaces in$libs/bus; the implementation in$busis reserved for the composition root (active-app) and tests.subscribe(),publishCausedBy(), the re-entrancy guard, the Svelte adapter, the active wrapper, cloneability checks and app-event runtime/payload guards are implemented. The only deliberate deferrals are listed in Deferred work.
bus is the framework's mechanical event bus. It transports facts
through typed envelopes with deterministic order, an explicit error
policy, and observability hooks. It does not know about session,
auth, cache, perm, connection, users, tenants, permissions, or any
business rule.
Applications never instantiate one bus per module. active-app creates a
single App.bus per render scope and injects it. Artifacts that need
to publish or listen receive that bus, or the smaller EventPublisher
interface, from the composition root.
Cross-artifact coordination is built on top of the bus with this flow:
artifact module events → active-app/orca translators → app events → consumer reactions
Position
bus is catalog-agnostic at runtime. EngineBus<TEvents> can be typed
for a composition surface such as ActiveAppBusEvents, but there is no
global event registry inside the bus engine. Type safety per module
comes from each owner declaring constants, payload shapes, and typed
publishX / onX helpers.
The framework distinguishes two layers of events that share a single
App.bus instance:
- Module events (
SESSION_EVENT_*,AUTH_EVENT_*,CACHE_EVENT_*, …) — internal facts emitted by the artifact that owns them. They can iterate; they are not part of the public contract. - App events (
APP_EVENT_*) — the stable public contract. Universal facts that consumers and external plugins listen to. Renaming or removing one is a breaking change.
Translators in arts/active-app/presets/* or service-specific bridges map module
events into app events. Consumers subscribe only to app events.
DDD reference
The model is Domain Events + Integration Events with an Anti-Corruption Layer:
| Active term | DDD term |
|---|---|
Module events (SESSION_EVENT_*, …) |
Domain events (private, iterable) |
App events (APP_EVENT_*) |
Integration events (public, stable) |
active-app/presets/* or service bridges |
Anti-corruption layer + event mapper |
| Per-consumer auto-reactions | Stateless process managers |
If you have a DDD background the model maps 1:1.
Layer split
arts/bus/ ← engine, framework-agnostic
types.ts EngineBus, BusEnvelope, BusListener,
BusPublishOptions, BusPublishResult,
EventPublisher, BusAnyListener
consts.ts BUS_*, listener error modes, diagnostics
engine-bus.ts createEngineBus()
no Svelte imports
errors.ts / matching.ts / diagnostics.ts
test/
arts/bus/svelte/ ← Svelte adapter
index.ts createSvelteEngineBus()
wraps listener invocation in untrack
arts/bus/active-bus.svelte.ts ← reactive wrappers (minimal surface)
createBusRecent() { lastEvent, count, clear, dispose }
arts/active-app/events.ts ← APP_EVENT_* constants + payloads
+ APP_EVENT_RUNTIMES metadata
arts/bus/svelte/context.svelte.ts ← getBus / setBus via createContext
arts/<module>/consts.ts ← module event constants
arts/<module>/types.ts ← module event payload types
arts/<module>/bus-helpers.ts ← typed publish/subscribe helpers
(the only path to call-site type safety)
arts/active-app/presets/ ← translators/reactions (anti-corruption layer)
identity-translator.ts module events → app events
permissions-translator.ts
tenant-translator.ts
connectivity-translator.ts
dispose-translator.ts
Hard rule: arts/bus/engine-bus.ts does not import from svelte.
Svelte-specific behaviour (untrack, runes wrappers, $effect.root)
lives in arts/bus/svelte/ and arts/bus/active-bus.svelte.ts. The
engine must be testable without DOM or Svelte runtime.
The EngineBus contract
export interface EngineBus {
publish<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
publishAsync<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): Promise<BusPublishResult<TType, TPayload>>;
/**
* Publish with `causationId` automatically set to `parent.id`. Use
* inside translators to keep the causation chain populated.
*/
publishCausedBy<TType extends string, TPayload>(
parent: BusEnvelope,
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
on<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): BusSubscription;
/**
* Sugar for `$effect`: returns the unsubscribe function directly so a
* Svelte component writes `$effect(() => Bus.subscribe(type, fn))`.
*/
subscribe<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): () => void;
onAny(listener: BusAnyListener<TEvents>, options?: BusListenOptions): BusSubscription;
once<TType extends string, TPayload>(
type: TType,
listener: BusListener<TPayload>,
options?: BusListenOptions
): BusSubscription;
listenerCount(type?: string): number;
_clearForTesting(type?: string): void;
dispose(): void;
}
export interface EventPublisher {
publish<TType extends string, TPayload>(
type: TType,
payload: TPayload,
options?: BusPublishOptions
): BusPublishResult<TType, TPayload>;
}
Envelope
export interface BusEnvelope<TType extends string = string, TPayload = unknown> {
readonly id: string;
readonly type: TType;
readonly payload: TPayload;
readonly at: number; // epoch ms
readonly source: string;
readonly correlationId?: string;
readonly causationId?: string;
readonly context?: Readonly<Record<string, unknown>>;
readonly tags?: readonly string[];
}
The current envelope is intentionally small and stable for the runtime:
identity, type, source, timestamp, payload and correlation/causation
metadata. It is CloudEvents-inspired, not a CloudEvents JSON object.
If an outbox or OpenTelemetry exporter needs CloudEvents later, that
adapter can map at to time and add specversion,
datacontenttype and subject without forcing the in-memory runtime to
carry those fields on every publish.
Engine options + invokeListener hook
export interface EngineBusOptions {
readonly logger?: Logger;
readonly clock?: BusClock;
readonly idFactory?: () => string;
readonly maxListenersPerEvent?: number; // default 32
readonly maxReentrancyDepth?: number; // default 32
readonly listenerErrorMode?: BusListenerErrorMode;
/**
* Called for every listener invocation. The Svelte adapter wraps with
* `untrack`. Default: identity.
*/
readonly invokeListener?: (fn: () => void | Promise<void>) => void | Promise<void>;
}
The engine never imports from svelte. The invokeListener hook is the
seam the Svelte adapter uses to wrap calls in untrack (see
Svelte/SvelteKit Runtime Contract).
Listener failure shape
BusListenerFailure carries enough context for distributed debugging:
export interface BusListenerFailure {
readonly listenerId?: string;
readonly type: string;
readonly envelopeId: string;
readonly correlationId?: string;
readonly causationId?: string;
readonly error: unknown;
}
BusAnyListener for onAny
onAny accepts a union of every event in the current bus map:
export type BusAnyListener<TEvents extends BusEventMap> = (
event: BusEventUnion<TEvents>,
context: BusListenerContext
) => void | Promise<void>;
The convention remains conservative: onAny is for diagnostics,
devtools, event capture and tests. Business side effects should subscribe
to the exact APP_EVENT_* they need.
Re-entrancy
Bus.publish() from inside a listener uses DFS (the new envelope
runs immediately, recursively). maxReentrancyDepth (default 32)
prevents accidental loops; exceeding it throws BusReentrancyLimitError.
This matches Node's EventEmitter semantics and is the least surprising
option.
Bus.publishAsync() from inside a listener is microtask-scheduled and
does not increment the synchronous depth counter.
Why no <TEvents> generic
A single global typed map would force every event the runtime might emit
to be declared in one place. That locks the framework to a closed
registry, blocks third-party plugins from adding their own events, and
conflates the bus (transport) with the catalog (which events exist).
Active therefore types each composition surface (ActiveAppBusEvents,
module test maps, plugin maps) locally while the engine stays generic.
Two-layer event model
Module events (internal, iterable)
Each module owns and emits its own facts. Constants live in the module's
consts.ts, payloads in types.ts, helpers in bus-helpers.ts.
// arts/session/consts.ts
export const SESSION_EVENT_CHANGED = 'session.changed';
export const SESSION_EVENT_IDENTITY_CHANGED = 'session.identity.changed';
export const SESSION_EVENT_REVOKED = 'session.revoked';
export const SESSION_EVENT_EXPIRED = 'session.expired';
export const SESSION_EVENT_REFRESHED = 'session.refreshed';
// arts/session/types.ts
export interface SessLifecyclePayload {
readonly event: SessionEvent;
readonly generation: number;
readonly identity: {
readonly from: SessionIdentityState;
readonly to: SessionIdentityState;
};
}
// arts/session/bus-helpers.ts
import type { BusPublishOptions, EventPublisher } from '$libs/bus';
// One publisher fans out to every event the lifecycle implies.
// `session.changed` is always emitted; `session.identity.changed` only when
// identity actually transitions; `session.revoked` / `session.expired` /
// `session.refreshed` only for the matching lifecycle event.
export function publishSessLifecycleEvent(
bus: SessEventPublisher,
payload: SessLifecyclePayload,
options: BusPublishOptions = {}
): void {
if (payload.event === SESSION_EVENT_LIFECYCLE_INITIAL) return;
const opts = { source: SESSION_MODULE, ...options };
bus.publish(SESSION_EVENT_CHANGED, payload, opts);
if (payload.identity.from !== payload.identity.to) {
bus.publish(SESSION_EVENT_IDENTITY_CHANGED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_REVOKED) {
bus.publish(SESSION_EVENT_REVOKED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_EXPIRED) {
bus.publish(SESSION_EVENT_EXPIRED, payload, opts);
}
if (payload.event === SESSION_EVENT_LIFECYCLE_REFRESHED) {
bus.publish(SESSION_EVENT_REFRESHED, payload, opts);
}
}
// Subscriber: one event, one listener. `onSessChanged` covers every
// lifecycle transition; subscribe to `SESSION_EVENT_IDENTITY_CHANGED` /
// `SESSION_EVENT_REVOKED` / etc directly when you only care about a slice.
export function onSessChanged(
bus: EngineBus<SessEventMap>,
listener: (event: SessChangedEnvelope) => void | Promise<void>
): BusSubscription {
return bus.on(SESSION_EVENT_CHANGED, (event) => listener(event as SessChangedEnvelope));
}
Module events may change between minors (rename, payload addition, deprecation). Only translators consume them.
App-owned events
The only event App publishes is APP_EVENT_DISPOSE_STARTING, fired at
the start of App.dispose() via publishAppDisposeStarting() in
arts/active-app/events.ts.
Cross-module reactions (cache.clear on identity change, perm.invalidate
on revoke, connections.reauth on identity change) are not bus
re-publications: they live as orca actions registered through the
presets in arts/active-app/presets/. Modules
publish their own typed events (SESSION_EVENT_* etc.) directly on
App.bus; orca subscribes and runs the registered actions.
App.bus is always-present per render scope
active-app creates the bus automatically; it is not a factory and never
optional. The bus is per request on server, per root on client —
never a module singleton (see Rule 1 below).
// arts/active-app/active-app.svelte.ts (sketch)
import { createSvelteEngineBus } from '$bus/svelte';
const Bus = createSvelteEngineBus({
logger: Logger,
clock: Timers.clock
});
const App = { Logger, Lang, Format, Dom, Storage, Http, Timers, Bus, Cache };
Modules accept EngineBus or EventPublisher from $bus and use
whatever App hands them. They must not import another artifact just to
observe its private events.
Svelte/SvelteKit Runtime Contract
These rules are runtime contract, not recommendations. Each rule has a mandatory test (see Mandatory tests).
Rule 1 — No mutable bus singleton
// FORBIDDEN
export const Bus = createEngineBus();
SvelteKit servers are long-lived processes shared by every concurrent
request. A module-scoped Bus is shared across requests and leaks
listeners and events between users.
The bus must be instantiated per request on server, per root on client:
Server request: hooks.server.ts → event.locals.bus = createEngineBus(...)
Client app: root component → const bus = createSvelteEngineBus(...);
setBus(bus)
Server bus and client bus are independent instances. They do not
share state. Only serializable data crosses the boundary (session
snapshot, identity, tenant) via event.locals and SvelteKit's load-data
flow. Never serialize the bus itself.
Rule 2 — Inject by context, not by import
Inside Svelte components, App.bus is consumed via context. The helper
lives in arts/bus/svelte/context.svelte.ts and is re-exported from
$bus:
// arts/bus/svelte/context.svelte.ts (excerpt)
import { getContext, setContext } from 'svelte';
import { BusNoContextError } from '$libs/bus';
import type { EngineBus } from '../types';
const BUS_CONTEXT = Symbol('arts.bus.context');
export function setBus(bus) { setContext(BUS_CONTEXT, bus); return bus; }
export function getBus() {
const bus = getContext(BUS_CONTEXT);
if (!bus) throw new BusNoContextError();
return bus;
}
Root component sets it; descendants read it:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { setBus } from '$bus';
setBus(App.bus);
</script>
Factory injection (passing a bus argument explicitly to a service or
test runtime) remains the override path for tests and non-Svelte
contexts.
Rule 3 — $effect is the canonical subscription pattern
Inside components, use Bus.subscribe() (returns the unsubscribe
function directly) inside $effect:
<script lang="ts">
import { SESSION_EVENT_IDENTITY_CHANGED } from '$session';
const Bus = getBus();
$effect(() =>
Bus.subscribe(SESSION_EVENT_IDENTITY_CHANGED, (event) => {
// react
})
);
</script>
$effect cleanup runs on unmount and on re-execution. subscribe()
returns the teardown directly so the effect can return it without an
intermediate variable. $effect does not run during SSR, so this
pattern is safe.
For services, plugins, or scripts top-level (not inside a component),
use Bus.on() and call sub.unsubscribe() manually or pass an
AbortSignal.
Rule 4 — Listeners run inside untrack (Svelte adapter)
Reading $state from a listener invocation creates a reactive
dependency against whatever $effect or $derived is active at publish
time. That is rarely intended. The Svelte adapter wraps every listener
invocation in untrack:
// arts/bus/svelte/index.ts
import { untrack } from 'svelte';
import { createEngineBus, type EngineBusOptions } from '$bus';
export function createSvelteEngineBus(options: EngineBusOptions = {}) {
return createEngineBus({
...options,
invokeListener: (fn) => untrack(fn)
});
}
The pure engine never imports from svelte. The adapter does. Listeners
that genuinely want reactive reads opt in explicitly.
Rule 5 — Payloads must be plain serializable data
Bus payloads must be cloneable. In DEV the bus runs structuredClone(payload)
and throws BusInvalidPayloadError on failure. This catches:
- functions, class instances without
Symbol.cloneable; - DOM nodes;
- non-cloneable types (sockets, file handles).
Reactive $state proxies are technically cloneable but must not be
published as-is: listeners reading from the published value would
create transitive dependencies. Callers wrap reactive state with
$state.snapshot(...) before publish:
publishSessLifecycleEvent(Bus, $state.snapshot(payload));
Rule 6 — Bus per render scope, disposed on tear-down
component unmount → $effect cleanups run, listeners unsubscribe
App.dispose() → factories disposed → translators torn down
→ Bus.dispose() → roots disposed
$effect.root is not used inside the engine. It may be used inside
active-bus.svelte.ts for reactive wrappers that outlive a single
component's lifecycle.
Rule 7 — Server / client / both-runtime events
Each APP_EVENT_* declares where it is allowed to fire:
// arts/active-app/events.ts
export const APP_EVENT_RUNTIMES: Readonly<Record<string, AppEventRuntime>> = {
[APP_EVENT_DISPOSE_STARTING]: APP_EVENT_RUNTIME_BOTH
};
export type AppEventRuntime =
| typeof APP_EVENT_RUNTIME_BOTH
| typeof APP_EVENT_RUNTIME_CLIENT;
export function assertEventCanFire(type: string, where: 'server' | 'client'): void {
const runtime = APP_EVENT_RUNTIMES[type];
if (runtime && runtime !== 'both' && runtime !== where) {
throw new AappInvalidEventRuntimeError(type, runtime, where);
}
}
The app-event helpers call assertEventCanFire before publishing.
The bus itself stays mechanical and artifact-agnostic; it does not know
what an app identity, tenant or connectivity event means. Wrong-side
publishes through publishApp* helpers (for example connectivity on the
server) throw early instead of silently misbehaving. Constants stay as
strings; metadata lives in a parallel table.
Mandatory tests
Eight tests are required for the v0.1 gate:
| Test | What it verifies |
|---|---|
| SSR isolation | A listener registered against request A's bus never sees request B's events. |
| No singleton | Importing the bus module twice yields no shared mutable state. |
| Context isolation | Two rendered app roots have different bus instances via getBus(). |
| Svelte cleanup | $effect-registered listener is unsubscribed when the component unmounts. |
| Untrack | Publishing during a $effect run does not create accidental reactive deps. |
| Payload safety | DEV-mode structuredClone check rejects non-cloneable payloads. |
| Runtime guard | Publishing a client-only event on the server throws in DEV. |
| Flush test | flushSync(() => Bus.publish(...)) makes DOM updates observable synchronously. |
Cross-module reactions live in orca, not on the bus
Earlier drafts of this art proposed a "translator" layer that turned
module events (SESSION_EVENT_*) into app events (APP_EVENT_*) and a
per-consumer autoInvalidateOn / autoReauthOn config that decided who
reacted to what. The big-bang refactor (commits 64ab1f0, c6de4a1)
removed that machinery entirely.
The model now is:
module emits SESSION_EVENT_IDENTITY_CHANGED on App.bus
-> orca picks up the event (it subscribed lazily on first
action registration)
-> orca runs every action registered for that event
-> actions call App.cache.clear(), App.perm.invalidate(), etc.
Reactions are declared as orca actions — registered through the
presets in arts/active-app/presets/ and wired
en bloc by applyStandardOrca(App):
import { createActiveApp, applyStandardOrca } from '$active-app';
const App = createActiveApp({
services: {
cache: defineActiveCache({}),
perm: defineActivePerm({ endpoint: '/api/perm' }),
session: defineActiveSession({ ... })
}
});
applyStandardOrca(App);
// → SESSION_EVENT_IDENTITY_CHANGED runs cache.clear() + perm.invalidate()
// → SESSION_EVENT_REVOKED runs cache.clear()
Cherry-pick when the standard set is too aggressive:
import {
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange
} from '$active-app';
applyCacheClearOnIdentityChange(App);
applyPermInvalidateOnIdentityChange(App);
The bus's job is to carry typed events between modules. It does not re-publish, classify, or react. The "consumer rules" reduce to: the consumer registers an orca action; the bus stays dumb.
The consequence for module owners writing a new art: publish your
<MODULE>_EVENT_* events directly on App.bus and document them. If a
later integration needs to react across modules, the integration ships
as an orca preset, not as code inside your art.
Reactive wrappers (active-bus.svelte.ts)
Most components want a reactive view of the latest event of a given type. v0.1 ships a minimal active wrapper:
// arts/bus/active-bus.svelte.ts
export function createBusRecent<TPayload>(bus: EngineBus, type: string) {
let lastEvent = $state<BusEnvelope<string, TPayload> | undefined>();
let count = $state(0);
const unsubscribe = bus.subscribe(type, (event) => {
lastEvent = event as BusEnvelope<string, TPayload>;
count += 1;
});
return {
get lastEvent() { return lastEvent; },
get count() { return count; },
clear() { lastEvent = undefined; count = 0; },
dispose: unsubscribe
};
}
Surface stays minimal in v0.1: lastEvent, count, clear(),
dispose. Anything richer waits until real usage patterns emerge.
App-owned event catalog
Only one event survived the big-bang refactor:
| Constant | Meaning |
|---|---|
APP_EVENT_DISPOSE_STARTING |
App.dispose() entered, last chance to flush listeners |
Everything else (identity changes, tenant switches, connectivity, cache
invalidation, permission refresh) is now expressed as module events
owned by their respective arts (SESSION_EVENT_*, etc.) and consumed
through orca actions registered at the App level.
A bus with 50 app events is the same god-object problem at a different layer; resist promoting module events to app events unless the fact is genuinely cross-cutting.
Credential safety on the bus
Bus payloads are public contract. They end up in logs, devtools, audit trails, and (in the future) outbox / replay. Their payloads must never carry credentials.
Two protections:
-
Typed payload restriction. Payload interfaces must not declare keys named
token,secret,password,hash,authorization,credential(case-insensitive). -
Pre-publish guard.
publishAppDisposeStarting()callsassertAppEventPayloadSafe(...)(in arts/active-app/events.ts) and throwsAappUnsafeEventPayloadErrorwhen a payload key contains a sensitive part such astoken,secret,password,hash,authorization,cookie,credential,csrforheader. Module-event publishers should apply the same hygiene — passcorrelationIdand let subscribers resolve sensitive context fromApp.sessionthemselves.
What does not belong on the bus
- Private module notifications:
Connection.onState,Cache.on,Session.onChangestay where they are. Intra-module reactivity is not a cross-module fact. - Logs.
Loggeris its own pipeline. The bus may produce diagnostics throughLogger, never the inverse. - High-frequency events (cursor position, scroll, keystrokes). Use regular DOM channels for those.
- Perceptual signals (taxis sema).
EngineSemanticis a different registry for a different purpose; do not unify. - Stor's internal entry-bus. Per-
EngineStoragesynchronization stays insidestorage; it is notApp.bus.
Testing patterns
import { flushSync } from 'svelte';
import { publishAppDisposeStarting } from '$active-app';
flushSync(() => {
publishAppDisposeStarting(Bus, { cause: 'test' });
});
expect(screen.getByText('disposing')).toBeVisible();
flushSync forces pending Svelte updates to apply immediately. Without
it, a publish followed by a DOM expectation is racy.
For strict tests, throw on listener errors:
const Bus = createEngineBus({
listenerErrorMode: 'throw',
logger: testLogger
});
For isolating a feature, disable translators:
createActiveApp({ orchestration: 'silent' });
// publish app events manually, assert reactions
API stability
Frozen for 0.1.x:
EngineBus,EventPublisher,BusEnvelope,BusPublishResult,BusSubscription,BusListener,BusListenerContext,BusListenerErrorMode,BusAnyListener,BusEventUnion.- Generic
BUS_*constants (modes, defaults,BUS_EVENT_ALL,BusInvalidPayloadError,BusReentrancyLimitError). - All
APP_EVENT_*constants and their payload types. - Sync-vs-async semantics, listener order, error handling contract.
- Re-entrancy semantics (DFS + maxReentrancyDepth).
Free to iterate inside 0.1.x:
- Module events (
SESSION_EVENT_*,AUTH_EVENT_*, …) and their payloads. - Internal diagnostics constants.
- Translator/reaction implementations under
arts/active-app/presets/.
Bundle budget
arts/bus core (engine only): target ≤ 3 KB gzip.
Status: gate is not yet wired into scripts/bundle-smoke.mjs. v0.1
must close this gap before tagging — an unenforced budget is no budget.
The Svelte adapter (arts/bus/svelte/) and active-bus.svelte.ts are
not counted against this budget; they are paid for only when used.
Current implementation status
Implemented:
EngineBus<TEvents>andEventPublisher<TEvents>interfaces in$libs/bus.publish,publishAsync,publishCausedBy,on,once,onAny,subscribe, listener counts, test cleanup and idempotentdispose.- Deterministic listener order;
onceis removed before invocation;onAnyreceives the envelope after exact listeners. - Per-publish listener error override through
BusPublishOptions.listenerErrorMode. - Re-entrancy DFS with
maxReentrancyDepthandBusReentrancyLimitError. - DEV cloneability guard with
BusInvalidPayloadError. - Svelte adapter
createSvelteEngineBus()viainvokeListener+untrack. - Minimal active wrapper
createBusRecent(). arts/active-app/events.tsapp-event constants, runtime metadata,publishApp*helpers,onApp*helpers and unsafe-payload guard.- App-level session translator and dispose-starting publisher.
- Per-consumer reactions for Cache, Perms and Connections.
Deferred work
Out of scope for the current cut:
prioritynumeric on listeners — use named phases in v1 if needed.AddEventCascadeAPI — sugar overon(A, () => publish(B)).- Standard Schema validation per app event — TS types + credential lint cover v0.1.
- Per-listener timeout in
publishAsync— current behaviour: waits for all. Add timeout in v0.2. - Replay, outbox, bounded queue, distributed bus, AsyncAPI catalog.
- Active wrapper beyond
createBusRecent(history, derived stores). - Server↔client bus serialization. Forbidden.
Naming convention reminder
BUS_*— generic bus constants (live inarts/bus).APP_EVENT_*— public app event names (live inarts/active-app/events.ts).<MOD>_EVENT_*(SESSION_EVENT_*,AUTH_EVENT_*,CACHE_EVENT_*,PERM_EVENT_*,CONNECTION_EVENT_*) — module event names, live in their owning artifact'sconsts.ts.- Diagnostic constants are full-prefixed strings, never bare names:
'bus.event.published', not'event_published'. - Helpers follow
publish<Domain><Verb>/on<Domain><Verb>.
Invariants
arts/busdoes not import other artifacts.arts/busdoes not export domain event constants. Domain events belong to their owning artifact;APP_EVENT_*belongs toarts/active-app.- Events describe what happened; they never command another module to do something.
- Automatic side-effects are opt-in per consumer.
- App events are public contract; they never carry credentials.
- The bus is per request on server, per root on client. Never a module singleton.