You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/architecture/active-app.md

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 between ActiveApp, ActiveUix and 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 the ActiveAppOptions root.
  • Services — opt-in pieces that the application declares in services: { … }. If a service is not declared, it does not exist on App, 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;
}
  • logger defaults to engine defaults (level: WARN, consoleTransport()). Pass { level: NONE, transports: [] } for silence.
  • bus and timers are App-wide singletons. Services that need them declare 'bus' / 'timers' in coreDependencies.
  • orca is 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() publishes APP_EVENT_DISPOSE_STARTING first, 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 when service-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:

  1. Build the art as a normal arts/<name>/ module. The art does not know about App or services; it exposes a pure createActive<Name> or createEngine<Name> factory.
  2. Write the define* factory in arts/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 from service-factories/index.ts.
  3. Optional — add presets in arts/active-app/presets/<name>-…ts for 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.

Powered by TurnKey Linux.