15 KiB
| title | type | audience | authority | status | source |
|---|---|---|---|---|---|
| ActiveApp — the arts composition root | reference | human + agent | E1 architecture — how the arts ecosystem composes into a runtime App (fixed core + declared services + orchestration) | current | migrated from src/arts/active-app/README.md (2026-07-03, arts-docs-reconciliation B1) |
active-app
arts/active-app is the composition layer of the ecosystem. It builds the
fixed runtime core, composes opt-in services declared by the application, and
exposes the orchestration engine that wires them together.
Whole-system architecture:
architecture/active-architecture.md. The analogue composition root for the component layers:architecture/active-uix.md. Executable contract betweenActiveApp,ActiveUixand the UIX layers:src/uix/contracts.ts.
Quick start
ActiveApp keeps the core (logger, bus, timers, orca, prefs) and
composes only the services the application declares in services. It does not
absorb UIX-specific decisions — visual preferences (theme, mode, density)
belong to Eidos, not to App.prefs.
import { createActiveApp } from '$active-app';
import {
defineActiveCache,
defineActiveLangs,
defineActiveSession,
defineEngineHttp
} from '$active-app/service-factories';
import { applyStandardOrca } from '$active-app/presets';
const App = createActiveApp({
logger: { level: LogLevel.INFO },
services: {
langs: defineActiveLangs({ schema: appLang, defaultLocale: 'es' }),
http: defineEngineHttp({ baseUrl: '/api' }),
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({
onRefresh,
onRevoke
})
}
});
applyStandardOrca(App);
Two layers, three import paths
active-app is layered to keep bundles small and the contract obvious.
| Layer | Path | Loaded when |
|---|---|---|
| Core | $active-app |
Always — every app needs createActiveApp. |
| Service factories | $active-app/service-factories |
The app declares any service in services: { … }. |
| Orchestration presets | $active-app/presets |
The app opts into standard reactions or cherry-picks them. |
Each layer is a separate barrel. An app that builds only the core never pulls service factories or presets into its bundle.
Core vs services
The composition has two layers:
- Core —
Logger,Bus,Timers,Orca. Always built, never declared as a service. Configurable via theActiveAppOptionsroot. - Services — opt-in pieces that the application declares in
services: { … }. If a service is not declared, it does not exist onApp, and TypeScript reports an error when consumers try to access it.
The legacy always-present service surface has been removed. App.langs,
App.cache, App.clipboard, App.format, App.dom, App.storage and
App.http exist only when the application declares those slots in services.
There is no migration period; the project did not have external consumers
when the cut happened.
What the core provides
interface ActiveAppCore {
readonly logger: EngineLogger;
readonly bus: EngineBus<ActiveAppBusEvents>;
readonly timers: ActiveTimers;
readonly orca: EngineOrca;
readonly prefs: ActivePrefs;
dispose(): void;
}
loggerdefaults to engine defaults (level: WARN,consoleTransport()). Pass{ level: NONE, transports: [] }for silence.busandtimersare App-wide singletons. Services that need them declare'bus'/'timers'incoreDependencies.orcais always present, inert until the application registers actions. Apps that don't use orchestration pay only for the engine's empty maps. See the orca README for the supported surface.dispose()publishesAPP_EVENT_DISPOSE_STARTINGfirst, then tears every constructed service down in reverse order, then the core.
How services work
A service is anything an AppServiceFactory produces. Factories live in
arts/active-app/service-factories/ and are exported from
$active-app/service-factories.
interface AppServiceFactory<TName, TCoreDeps, TServiceDeps, TInstance> {
readonly name: TName;
readonly coreDependencies: TCoreDeps;
readonly serviceDependencies?: TServiceDeps;
readonly initMode?: 'immediate' | 'lazy';
create(deps: { core: …; services: … }): TInstance;
dispose?(instance: TInstance): void;
}
The schema is just an object literal:
services: {
cache: defineActiveCache(),
session: defineActiveSession<MyUser>({ onRefresh, onRevoke })
}
The builder validates the schema, computes a topological order, builds
immediate services right away, and exposes lazy ones behind getters
that materialise on first access. Construction order is dependency-first;
disposal runs in reverse.
Service init modes
| Mode | When the service is built |
|---|---|
lazy (default) |
First time App.<name> is read. |
immediate |
During createActiveApp(), after the core is up. |
immediate is for services with construction-time side effects (subscribing
to BroadcastChannel, hydrating from storage on boot, etc.). Everything else
is lazy.
Service status
Every declared service has an observable status:
type ServiceStatus = 'absent' | 'present' | 'failed';
App.services; // Readonly<Record<string, ServiceStatus>>
Mostly used by devtools and tests; application code rarely reads it.
Failure handling
If a factory's create() throws, the service status becomes 'failed'.
Subsequent reads of App.<name> re-throw the original error wrapped in
AappServiceConstructionFailedError. The first read sees the same wrapped
error — the wrapping is cheap and uniform.
Available services
| Factory | Slot | Notes |
|---|---|---|
defineActiveLangs(options) |
langs |
Schema is required; follows core.prefs.language when that dimension exists. |
defineActiveStorage(options) |
storage |
Memory adapter by default. |
defineActiveClipboard(options) |
clipboard |
Lazy capability wrapper around navigator.clipboard.writeText or an injected writer. |
defineActiveDom(props) |
dom |
Inert on the server. |
defineActiveFormat(options) |
format |
Reads core.prefs.locale when that dimension exists. |
defineActiveCache(options) |
cache |
Passive — invalidation is driven by orca presets. |
defineActiveSession<TUser, …>(options) |
session |
Publishes SESSION_EVENT_* on the bus. |
defineActivePerm(options) |
perm |
Auto-invalidation is OFF; use orca preset. |
defineActiveAuth(options) |
auth |
Requires an HTTP client in options. |
defineActiveConnections(options) |
connections |
Identity tracking via orca preset. |
defineEngineHttp(options) |
http |
Engine only — no Active wrapper. |
defineEngineSium(options) |
sium |
Wires to langs automatically when declared. |
defineActiveAgent(options) |
agent |
timers + logger come from the App core; identity / policy / transport are the app's. |
defineEngineMotion(options) |
motion |
Engine only. Declares serviceDependencies: ['dom']; degrades without it. |
defineEngineScene(options) |
scene |
Engine only — the ambient-scene runtime the canon shares as uix.scene. |
defineEngineSound(options) |
sound |
Engine only — the Web Audio runtime extracted from sema's SoundChannel. |
This table is checked against the directory:
docs:check(I1-catalog) fails whenservice-factories/grows a slot this list does not mention. It had fallen four behind before that guard existed.
Orchestration
App.orca is always present and inert. Reactions are not pre-wired — apps
register them explicitly through orca presets in arts/active-app/presets/.
import {
applyCacheClearOnRevoke,
applyCacheClearOnIdentityChange,
applyPermInvalidateOnIdentityChange,
applyStandardOrca
} from '$active-app/presets';
// Cherry-pick:
applyCacheClearOnRevoke(App);
applyPermInvalidateOnIdentityChange(App);
// Or all standard presets at once:
applyStandardOrca(App);
Each apply* returns a detach function for testing and hot-reload.
Why presets live here, not inside arts
An art (arts/cache, arts/perm, …) does not know about arts/session or
arts/orca. That knowledge belongs to the composition layer. Putting presets
in arts/active-app/ keeps the inter-art dependency graph clean: every art
depends only on libs/ and on the core (logger, bus, timers, orca),
never on a sibling art.
Bus context bridge
The Svelte-context helper setBus / getBus lives in $bus, not here.
The bus is the semantic owner of the propagation pattern; App is just a
consumer that calls setBus(App.bus) once near the layout root.
<!-- app/+layout.svelte -->
<script lang="ts">
import { setBus } from '$bus';
import { App } from './app';
setBus(App.bus);
</script>
<!-- somewhere deep in the tree -->
<script lang="ts">
import { getBus } from '$bus';
const Bus = getBus();
$effect(() => Bus.on('something', (payload) => console.debug(payload)));
</script>
getBus() throws BusNoContextError (from $libs/bus) if no bus is in
scope — forgetting setBus() is a wiring bug, not a degraded mode.
Events
Only one event is owned by arts/active-app:
export const APP_EVENT_DISPOSE_STARTING = 'app.dispose.starting';
It fires once at the start of App.dispose(), before any service teardown,
so subscribers can flush, persist or detach while their dependencies still
exist. Everything else used to be a republication of module-level events;
those republications have been removed in favour of orca presets that listen
to the canonical events directly.
assertEventCanFire(type, where) and assertAppEventPayloadSafe(type, payload) are the safety nets used by typed publishers like
publishAppDisposeStarting. Both throw structured errors
(AappInvalidEventRuntimeError, AappUnsafeEventPayloadError) that
applications can catch.
Errors
| Error | When it fires |
|---|---|
AappServiceNameMismatchError |
Schema key !== factory.name. |
AappServiceDependencyCycleError |
A cycle is detected in serviceDependencies. |
AappServiceConstructionFailedError |
A factory's create() throws. |
AappInvalidEventRuntimeError |
An APP_EVENT_* published in the wrong runtime. |
AappUnsafeEventPayloadError |
A sensitive key (token, password, cookie, …) is found in a payload. |
getBus() throws BusNoContextError (from $libs/bus) when no bus is
in Svelte context — that error belongs to arts/bus/, not active-app/.
All of them extend CodeError from $libs/errs and have type guards
(isAappServiceNameMismatchError, …).
Filesystem layout
src/arts/active-app/
├── README.md ← stub → docs/architecture/active-app.md
├── index.ts ← public entry point ($active-app)
├── consts.ts
├── errors.ts
├── events.ts ← APP_EVENT_DISPOSE_STARTING + safety helpers
├── services.ts ← AppServiceFactory contract
├── service-builder.ts ← topology, lazy proxies, dispose
├── active-app.svelte.ts ← createActiveApp()
├── service-factories/ ← $active-app/service-factories
│ ├── index.ts ← the barrel
│ └── {slot}.ts ← ONE file per slot; the directory IS the
│ catalog (see "Available services" above —
│ this tree used to re-enumerate it and fell
│ four behind)
├── presets/ ← $active-app/presets
│ ├── index.ts
│ ├── cache-clear-on-revoke.ts
│ ├── cache-clear-on-identity-change.ts
│ ├── perm-invalidate-on-identity-change.ts
│ └── standard.ts
└── test/
└── service-builder.test.ts
Adding a new service
Three steps:
- Build the art as a normal
arts/<name>/module. The art does not know aboutApporservices; it exposes a purecreateActive<Name>orcreateEngine<Name>factory. - Write the
define*factory inarts/active-app/service-factories/<name>.ts. Declare which core deps you read (coreDependencies: ['logger', 'bus']) and which sibling services you optionally consume (serviceDependencies: ['langs']). Export it fromservice-factories/index.ts. - Optional — add presets in
arts/active-app/presets/<name>-…tsfor any reactions the standard composition wants to ship.
The service is then declarable from any application:
services: {
cart: defineActiveCart({ persistKey: 'cart' });
}
App.cart is now type-safe, lazy by default, and disposed in reverse order
when App.dispose() runs.
Test
npx vitest run src/arts/active-app/test
The current suite covers the schema validation, topological ordering, lazy/immediate construction, status reporting, dispose order, idempotence, and core/service dependency injection.