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.
active-svelte/docs/active-app-refactorizacion.md

80 KiB

Refactorización de arts/active-app — eliminación de eventos parche y modelo de servicios

Estado de este documento

ARCHIVADO — refactor completado 2026-05-04. El documento captura el análisis y plan de las fases 1-4 (eliminación de APP_EVENT_*, modelo de servicios declarativo, integración con orca). Todas las fases se ejecutaron; el contrato vivo es src/arts/active-app/README.md y el código mismo. Esta página se mantiene como registro histórico del razonamiento que guió el big-bang, no como guía operativa.

orca arrancó como v0-kernel durante este refactor y se completó al 100% en sesiones posteriores; ver src/arts/orca/README.md para el estado actual.


Resumen ejecutivo

Tres problemas convergentes:

  1. arts/cache, arts/perm, arts/connection se suscriben internamente al bus para reaccionar a APP_EVENT_*. Eso filtra vocabulario de App (tenant, refresh, identity) a piezas que deberían ser runtime puro.
  2. Los APP_EVENT_* son deuda técnica disfrazada: la mayoría son republicaciones de eventos cuyo dueño real es otro módulo (sesión publica identidad, conexión publica connectivity), o son comandos disfrazados de eventos (PERMISSIONS_REFRESH_REQUESTED, CACHE_INVALIDATE_REQUESTED).
  3. arts/active-app esconde side-effects de orquestación —violando una invariante explícita de arts/orca— porque hoy no existe orca como pieza de orquestación dedicada.

Decisión arquitectónica:

  • Eliminar todos los APP_EVENT_* salvo los que aapp dueña realmente (DISPOSE_STARTING).
  • Eliminar el session-translator y las suscripciones internas en cache/perm/connection.
  • Exponer API imperativa pública en cada artefacto (invalidate, refresh, cancelPrivateRequests, reauthenticateAll).
  • Reescribir aapp como compositor + factory + lifecycle sobre un esquema declarativo de servicios (AppServiceSchema).
  • Toda orquestación inter-modular se mueve a orca (cuando exista) o a bridge code explícito en arts/active-app mientras tanto.

1. Problema raíz

1.1 Acoplamiento concreto observado

Artefacto Línea Suscripciones internas
arts/cache/active-cache.svelte.ts 211–219 USER_IDENTITY_CHANGED, TENANT_SWITCHED
arts/perm/active-permissions.svelte.ts 151–165 USER_IDENTITY_CHANGED, PERMISSIONS_REFRESH_REQUESTED, TENANT_SWITCHED
arts/connection/bus-session-source.ts 10 USER_IDENTITY_CHANGED (vía createBusSessionSource)

Cada uno hace bus.on(APP_EVENT_X, () => método-interno()) para auto-reaccionar. La consecuencia:

  • arts/cache conoce el concepto "tenant".
  • arts/perm conoce el concepto "refresh".
  • arts/connection conoce el concepto "identity".

Si alguien intentara usar arts/cache fuera de Active framework, está obligado a entender qué es un tenant o a inyectar un bus que falsifique eventos app.*. Eso rompe la promesa "los arts son piezas runtime reusables".

1.2 Por qué los APP_EVENT_* son un parche

Evento Dueño real Naturaleza Veredicto
USER_IDENTITY_CHANGED session Republicación de SESSION_EVENT_LIFECYCLE_* Eliminar. Consumidores escuchan SESSION_EVENT_*.
PERMISSIONS_REFRESH_REQUESTED nadie Comando, no hecho Eliminar. Reemplazar por llamada imperativa App.perms.refresh().
CACHE_INVALIDATE_REQUESTED nadie Comando, no hecho Eliminar. Reemplazar por App.cache.invalidate(...).
TENANT_SWITCHED App-state Hecho de App-level Mantener provisional. Si tenant pasa a un módulo dueño en el futuro, eliminar.
CONNECTIVITY_CHANGED navegador / connection Estado del navegador Mover a connection como CONNECTION_EVENT_ONLINE/OFFLINE.
DISPOSE_STARTING App Lifecycle de aapp Mantener. Único evento donde App es realmente dueña.

La regla:

Un evento existe en App solo si App es el dueño del hecho. Comandos no son eventos. Las republicaciones no son eventos: son indirección.


2. Restricción arquitectónica: orca

arts/orca/README.md define el motor de orquestación que va a reemplazar las suscripciones internas. Documento extenso ya cerrado en su contrato.

2.1 Invariantes de orca relevantes para este refactor

Citas literales (ver arts/orca/README.md):

  • orca no importa artefactos concretos salvo contratos comunes.
  • los artefactos no consumen orca; solo publican eventos en buss.
  • la aplicacion registra acciones en orca.
  • sess, cach, perm, connection, auth o http no deben depender de orca para sus flujos internos.
  • aapp puede crear Bus, Timers, Logger y Orchestration, pero no debe esconder la politica de orquestacion.

Y de "Que problema resuelve":

Sin orca, las reacciones inter-modulo tienden a acabar repartidas:

sess conoce cach
cach conoce perm
connection conoce sess
aapp conoce todo

Eso escala mal.

2.2 Implicación: este refactor es pre-requisito de orca v0

Si orca registra una action en SESSION_EVENT_LIFECYCLE_REVOKED que invalida cache, y arts/cache también se suscribe internamente al mismo evento, hay doble reacción: race condition, double-invalidation, estado corrupto.

Por tanto el refactor de eliminar suscripciones internas en cache/perm/ connection no es opcional para que orca exista. O se hace ahora, o se hace como primer paso del proyecto orca. Conviene hacerlo ahora porque:

  • El acoplamiento conceptual es ruido en el código actual incluso sin orca.
  • Permite simplificar libs/active-app/events.ts drásticamente.
  • Permite mover libs/active-app/ → arts/active-app/ (la única razón de vivir en libs eran los consumidores externos cache/perm/conn que dejarán de existir).

3. Diseño emergente: aapp como compositor + servicios

aapp deja de tener lógica reaccional propia. Pasa a ser compositor explícito con dos secciones: núcleo (siempre presente, parte de su runtime) y servicios (declarados explícitamente por el desarrollador en un AppServiceSchema).

3.1 Distinción núcleo vs servicios

Núcleo (siempre presente, sin opt-in):

Servicio Responsabilidad
logger logger compartido
lang i18n
storage almacenamiento sync (con adapters)
format formateadores localizados
dom reactividad DOM
frontend preferencias de UI
bus event bus
timers scheduler de timers
orchestration orca (siempre presente, inerte hasta que se registren acciones)

Configurables vía las opciones que ya existen hoy en createActiveApp(). La configuración del núcleo no entra en services: — entra en la raíz del options object. Mantenemos los contratos ya definidos.

Servicios opcionales (opt-in):

Servicio Hoy es
sium factory lazy App.createSiumEngine()
session factory lazy App.createActiveSession<...>()
cache factory lazy App.createActiveCache(...)
perm factory lazy App.createActivePerms(...)
http factory lazy App.createEngineHttp(...)
auth factory lazy App.createActiveAuth(...)
connection factory lazy App.createActiveConnections(...)

Pasan a declararse en el schema. Si no se declaran, no existen en la app y el acceso (App.cache) es error de tipos.

3.2 Esquema base

const App = createActiveApp({
  // Núcleo (configurable; siempre presente)
  lang:    { schema: appLang, defaultLocale: 'es' },
  storage: { adapter: localStorageAdapter() },
  frontend: { theme: 'system' },

  // Servicios (opt-in declarativos)
  services: {
    cache: defineActiveCache({ adapter: 'memory' }),
    session: defineActiveSession<MyUser>({ refresh: refreshFn }),
    http: defineEngineHttp({ baseUrl: '/api' }),
  }
});

App.bus            // núcleo: tipo EngineBus
App.cache          // OK: declarado en services
App.session        // OK: declarado en services
App.perm           // ❌ TS error: no está en services
App.connections    // ❌ TS error: no está en services

3.3 Contrato base de un servicio

type ServiceState =
  | 'pending'       // declarado, aún no construido
  | 'initializing'  // construyéndose en este momento
  | 'running'       // operativo
  | 'failed'        // crash al iniciar
  | 'disposing'     // dispose en curso
  | 'disposed';     // ya destruido

type ServiceInitMode =
  | 'immediate'  // se construye en commit() de la app
  | 'lazy';      // se construye en el primer acceso (App.cache → trigger init)

interface AppService<TName extends string, TInstance> {
  readonly serviceName: TName;
  readonly initMode: ServiceInitMode;
  readonly dependencies: readonly string[]; // claves de núcleo o de otros servicios
  readonly state: ServiceState;             // observable público
  readonly instance: TInstance;             // la instancia construida
  readonly dispose: () => void | Promise<void>;
}

// Cada artefacto exporta un define*() que produce una factory tipada
interface AppServiceFactory<TName extends string, TDeps, TInstance> {
  readonly name: TName;
  readonly initMode: ServiceInitMode;
  readonly dependencies: readonly (keyof TDeps & string)[];
  create(deps: TDeps): TInstance;
  dispose?(instance: TInstance): void | Promise<void>;
}

Cada art expone su factory:

// arts/cache/index.ts
export function defineActiveCache(options: ActiveCacheOptions) {
  return {
    name: 'cache' as const,
    initMode: 'lazy' as const,
    dependencies: ['bus', 'logger', 'timers'] as const,
    create(deps: { bus: EngineBus; logger: EngineLogger; timers: TimerScheduler }) {
      return createActiveCache({ ...options, ...deps });
    },
    dispose(instance: ActiveCache) {
      instance.dispose();
    }
  };
}

3.4 Inicialización: lazy vs immediate

Modo Cuándo se construye Caso de uso
immediate Al hacer commit() (después de createActiveApp(...)) Servicios que la app necesita de salida (sesión inicial, http base)
lazy En el primer acceso App.cache Servicios que pueden no usarse en algunos flujos (cache cuando solo hay rutas estáticas)

Default por servicio: lo decide el define*() del art. La app puede sobrescribirlo:

services: {
  cache: defineActiveCache(options).withInitMode('immediate')
}

3.5 Validación del schema

Estática (al construir):

  • Servicios declarados que no existen como factory → error.
  • Dependencias declaradas que apuntan a un servicio no presente en el schema ni en el núcleo → error en createActiveApp() (compile-time vía tipos cuando sea posible; runtime al commit() si los tipos no llegan).
  • Ciclos de dependencias → error en commit().

Runtime:

  • Servicios immediate se construyen en orden topológico al commit().
  • Si la construcción de uno falla, su state queda en 'failed' y se aborta el commit() con error agregado.
  • Servicios lazy se construyen en el primer acceso; el error queda en su state.

3.6 Type-safety

ActiveApp se vuelve genérico sobre el schema:

type ActiveApp<TServices extends Record<string, AppService<string, unknown>>> =
  CoreApp & {
    [K in keyof TServices]: TServices[K]['instance'];
  } & {
    // Acceso a metadata
    services: {
      [K in keyof TServices]: AppService<K & string, TServices[K]['instance']>;
    };
  };

Resultado: si declaras cache y session, App.cache y App.session existen con su tipo correcto y App.perm falla en compile-time.

3.7 Orquestación

La orquestación no entra en el schema de servicios. Va por orca:

// El developer registra acciones en App.orchestration, no en el schema
App.orchestration.onEvent(SESSION_EVENT_LIFECYCLE_REVOKED, {
  id: ORCA_ACTION_INVALIDATE_CACHE,
  stage: ORCA_STAGE_MAIN,
  action: async () => {
    await App.cache.invalidate({ on: 'userIdentityChange' });
    return orcaSuccess();
  }
});

Razón: orca tiene su propio sistema (stages, tokens, policies) que no pertenece al schema declarativo de servicios. Mezclarlos hace que el schema crezca a un DSL paralelo de orca, redundante.

Mientras orca v0 no exista, aapp puede aceptar un slot opcional bridges: con suscripciones provisionales del estilo bus.on(EVENT, () => App.X.method()). Comentado claramente como código transitorio. Cuando orca llegue, esas líneas se reemplazan por Orca.onEvent().


4. Decisiones cerradas

  1. Modelo: núcleo (siempre presente) + servicios (opt-in declarado).
  2. Naming: createActiveApp(options) (no new ActiveApp(...)); helper de servicio defineActive* / defineEngine*. La interfaz base se llama AppService (sin "Active" — el "Active" del framework significa $state-reactivo y no aplica a todos los servicios).
  3. Tipo del schema: services: { [K in TName]: AppServiceFactory<...> } tipado, con K literal para inferencia.
  4. Servicios declarados que no existen como factory: error.
  5. Dependencias faltantes: error en compile-time (cuando los tipos alcanzan) y en commit() runtime como respaldo.
  6. Init mode por servicio: lazy por defecto en factories de servicios opcionales; immediate solo si el define*() lo declara así. Override en la declaración del schema permitido.
  7. Núcleo configurable vía opciones existentes (lang, storage, frontend, …) en la raíz del options object.
  8. Orquestación: vive en orca, no en el schema de servicios.
  9. AppEventBus, APP_EVENT_*: sobreviven solo DISPOSE_STARTING. El resto se elimina. libs/active-app/events.ts se reduce a este único evento (o desaparece, ver §6).
  10. session-translator: se elimina. orca (o el bridge provisional) escucha SESSION_EVENT_LIFECYCLE_* directamente.

5. Decisiones abiertas

  1. Ubicación final de libs/active-app/: una vez vaciado, ¿se mueve todo a arts/active-app/ (sin libs) o se mantiene libs/active-app/ con solo consts.ts y errors.ts? Recomendación: mover todo a arts/active-app/. Solo el código del núcleo y los servicios es runtime; no hay contrato puro reusable que justifique una capa abstracta.
  2. Tenant: ¿quién dueña el cambio de tenant? Si se confirma que es App, APP_EVENT_TENANT_SWITCHED sobrevive. Si pasa a un módulo (futuro tenant), se elimina. Acción: investigar consumidores reales y decidir.
  3. Connectivity: ¿arts/connection ya publica CONNECTION_EVENT_ONLINE/ OFFLINE? Si sí, APP_EVENT_CONNECTIVITY_CHANGED se elimina y los listeners migran. Acción: verificar antes de Fase 1.
  4. bridges: provisional vs forzar orca desde día uno: ¿conviene meter las suscripciones provisionales en aapp con un slot dedicado o esperar a orca?
  5. Eager construction en commit(): ¿el orden topológico se calcula automáticamente o se exige al desarrollador declararlo? Recomendación: automático con detección de ciclos.

6. Plan de refactor por fases

Fase 1 — Eliminar eventos parche (ejecutable inmediatamente)

Objetivo: dejar arts/cache, arts/perm, arts/connection sin suscripciones internas a eventos APP_EVENT_*. Eliminar el session- translator. Reducir libs/active-app/events.ts a DISPOSE_STARTING (y posiblemente TENANT_SWITCHED si decidimos mantenerlo).

Riesgo: alto. Cambia el comportamiento "auto-invalidate" que hoy hacen los arts. Tests que asumen ese comportamiento se rompen.

Subpasos:

  1. 1A — Verificar dueños reales:
    • Confirmar que connection publica CONNECTION_EVENT_ONLINE/OFFLINE. Si no, considerar que connection lo añada antes de eliminar APP_EVENT_CONNECTIVITY_CHANGED.
    • Confirmar que tenant no tiene un módulo dueño y que mantenerlo en App es la opción correcta.
  2. 1B — Exponer API imperativa pública en arts/cache, arts/perm, arts/connection para los efectos hoy automáticos:
    • cache.invalidate({ on: 'userIdentityChange' | 'tenantSwitched' | ... })
    • perm.invalidate(), perm.refresh({ cause? })
    • connections.adoptIdentity(id), connections.reauthenticateAll()
  3. 1C — Eliminar suscripciones internas en los active-*.svelte.ts de los tres arts. Conservar (de momento) el option bus?: AppEventBus para no romper firmas de creación.
  4. 1D — Eliminar session-translator.ts y sus tests.
  5. 1E — Mover suscripciones provisionales a aapp:
    • arts/active-app/active-app.svelte.ts registra bus.on(SESSION_EVENT_*, () => app.cache?.invalidate(...)) como código transitorio.
    • Comentado: // PROVISIONAL: when orca v0 lands, replace with App.orchestration.onEvent(...)
  6. 1F — Eliminar publishers/payloads/helpers de libs/active-app/events.ts salvo DISPOSE_STARTING. Tests que publicaban publishAppUserIdentityChanged(...) migran a publicar SESSION_EVENT_LIFECYCLE_* directamente o a llamar la API imperativa.
  7. 1G — Limpiar imports en arts/cache, arts/perm, arts/connection que ya no apunten a $libs/active-app/events.
  8. 1H — Actualizar artifact-docs.ts para reflejar la realidad.
  9. 1I — Tests + commit.

Fase 2 — Exposición sólida de API imperativa

Si Fase 1 deja la API imperativa "como mejor se pudo", Fase 2 audita y estabiliza:

  • Firma uniforme cancelable(): { dispose(): void } para suscripciones externas si las hay.
  • Documentar contractualmente qué métodos cada art expone para ser invocados desde orca.
  • Consolidar nombres (invalidate vs clear, refresh vs reload).

Fase 3 — Modelo de servicios

Implementar AppServiceSchema, AppService, AppServiceFactory, createActiveApp({ services: { … } }) con type-safety, init modes, validación.

Subpasos:

  1. Definir tipos en arts/active-app/services.ts.
  2. Cada art expone su defineActive*() / defineEngine*() factory.
  3. Reescribir createActiveApp() para construirse desde el schema.
  4. Migrar las apps cliente (web/routes) a la nueva API.
  5. Eliminar las firmas legacy App.createActive*().

Fase 4 — Integración con orca v0

Cuando arts/orca/ esté implementado:

  1. App.orchestration (orca) reemplaza al bridge code provisional de Fase 1E.
  2. Los registros provisionales bus.on(SESSION_EVENT_*, ...) se reescriben como App.orchestration.onEvent(SESSION_EVENT_*, { id, stage, action }).
  3. aapp deja de tener cualquier bus.on() directo. Solo compose + factory + lifecycle.

7. Notas para evaluadores

7.1 Qué validar antes de mergear este diseño

  • ¿La distinción núcleo/servicios cubre todos los casos? Por ejemplo, dom está en núcleo pero solo tiene sentido en cliente. Quizás dom y frontend deberían ser servicios opcionales con default present en cliente y absent en server.
  • ¿orchestration (orca) en el núcleo es lo correcto, o debería ser un servicio opcional para apps que no orquestan nada?
  • ¿La separación libs/X (contrato) vs arts/X (runtime) sigue justificándose para todos los módulos, o solo cuando hay consumidores en capa abstracta?

7.2 Riesgos no resueltos

  • API imperativa actualmente parcial: cache.invalidate({ on: ... }) hoy sí existe; perm.refresh() también. Pero se pasaba el flag on: a un option que el art interpretaba; con la nueva API, debe pasarse explícitamente desde la action de orca o desde el bridge provisional.
  • Tests de ecosistema asumen comportamiento auto-reactivo. Migrar test por test es trabajo manual no automatizable.
  • bus-session-source en arts/connection es un caso de adapter de App a Connection. Considerar moverlo a arts/active-app/integrations/ para no contaminar connection con vocabulario de App.

7.3 Coherencia con memoria del usuario

Este diseño respeta:

  • "arts/ boundaries — runtime only"*: arts dejan de filtrar vocabulario de App.
  • "Define dependencies in the API, not via silent fallbacks": el schema hace explícitas las dependencias entre servicios.
  • "Minimal API surface": factories define*() no añaden helpers redundantes; reusan los createActive*() ya existentes.
  • "professional-grade design preferences": sin acoplamientos ocultos, sin runtime arg-shape magic, type-safety completa.

Apéndice A — Mapa de eventos APP_EVENT_*

APP_EVENT_USER_IDENTITY_CHANGED         → eliminar (republicación de SESSION_EVENT_LIFECYCLE_*)
APP_EVENT_PERMISSIONS_REFRESH_REQUESTED → eliminar (comando → App.perm.refresh())
APP_EVENT_CACHE_INVALIDATE_REQUESTED    → eliminar (comando → App.cache.invalidate(...))
APP_EVENT_CONNECTIVITY_CHANGED          → mover a arts/connection (CONNECTION_EVENT_*)
APP_EVENT_TENANT_SWITCHED               → mantener provisional (App es dueña actualmente)
APP_EVENT_DISPOSE_STARTING              → mantener (App es dueña real del lifecycle)

Apéndice B — Tipos preliminares

// arts/active-app/services.ts (a crear en Fase 3)

export type ServiceState =
  | 'pending'
  | 'initializing'
  | 'running'
  | 'failed'
  | 'disposing'
  | 'disposed';

export type ServiceInitMode = 'immediate' | 'lazy';

export interface AppServiceFactory<
  TName extends string,
  TDeps extends Record<string, unknown>,
  TInstance
> {
  readonly name: TName;
  readonly initMode: ServiceInitMode;
  readonly dependencies: readonly (keyof TDeps & string)[];
  create(deps: TDeps): TInstance;
  dispose?(instance: TInstance): void | Promise<void>;
}

export interface AppService<TName extends string, TInstance> {
  readonly serviceName: TName;
  readonly initMode: ServiceInitMode;
  readonly dependencies: readonly string[];
  readonly state: ServiceState;
  readonly instance: TInstance;
  readonly dispose: () => void | Promise<void>;
}

export type AppServiceSchema = Record<string, AppServiceFactory<string, never, unknown>>;

export type ResolveAppInstances<S extends AppServiceSchema> = {
  [K in keyof S]: S[K] extends AppServiceFactory<infer _N, infer _D, infer I>
    ? I
    : never;
};

Apéndice C — Ejemplo de uso final

import { createActiveApp } from '$active-app';
import { defineActiveCache } from '$cache';
import { defineActiveSession } from '$session';
import { defineEngineHttp } from '$http';
import { SESSION_EVENT_LIFECYCLE_REVOKED } from '$session';
import { ORCA_STAGE_MAIN, orcaSuccess } from '$orca';

const App = createActiveApp({
  // Núcleo configurable
  lang: { schema: appLang, defaultLocale: 'es' },
  storage: { adapter: localStorageAdapter() },
  frontend: { theme: 'system' },

  // Servicios opt-in (opcionales, declarados)
  services: {
    cache: defineActiveCache({ adapter: 'memory' }),
    session: defineActiveSession<MyUser>({
      refresh: refreshFn
    }).withInitMode('immediate'),
    http: defineEngineHttp({ baseUrl: '/api' }),
  }
});

// Orquestación explícita vía orca (no escondida en services:)
App.orchestration.onEvent(SESSION_EVENT_LIFECYCLE_REVOKED, {
  id: 'app.invalidate-on-revoke',
  stage: ORCA_STAGE_MAIN,
  action: async () => {
    await App.cache.invalidate({ on: 'userIdentityChange' });
    return orcaSuccess();
  }
});

// Type-safety:
App.cache       // ✅ ActiveCache
App.session     // ✅ ActiveSession<MyUser>
App.http        // ✅ EngineHttp
App.perm        // ❌ TS error: 'perm' is not in services schema
App.connections // ❌ TS error


8. Revisión arquitectónica recibida — round 1

Llegó un análisis externo del código actual con tres observaciones que el diseño inicial no había nombrado.

8.1 Tres mecanismos de orquestación compitiendo

active-app.svelte.ts no tiene un solo problema de acoplamiento, tiene tres mecanismos paralelos, cada uno reinventando orquestación con menos rigor que el anterior:

  1. Suscripciones internas en los servicios (Cache, Perms, Connection) — el problema diagnosticado en §1.
  2. wireSessionTranslator — republica SESSION_EVENT_LIFECYCLE_* como APP_EVENT_USER_IDENTITY_CHANGED. Combinado con los presets APP_ORCHESTRATION_TRANSLATOR_* declarados en consts.ts, esto es un orca embrionario sin tokens, sin stages, sin políticas, enterrado en aapp.
  3. createAuthCacheInvalidator — orquestación implícita auth ↔ cache ↔ perms con 0 trazabilidad.

Conclusión arquitectónica: ya estás haciendo orca, solo que sin formalizar. Cuando llegue, no añade complejidad: la reemplaza.

8.2 Asimetría singleton vs multi-create

Hoy:

  • createActiveSession, createActivePerms, createActiveAuth lanzan *AlreadyCreatedError si se llaman dos veces.
  • createActiveConnections mantiene un Set<ActiveConnections> y permite múltiples instancias.

El modelo de servicios borra esta asimetría: una instancia por servicio declarado. connections pasa a singleton. El caso raro de "varios registries" se resuelve en código de aplicación, no en infraestructura.

8.3 La distinción "siempre presente" vs "factory" es ruido

Cache se construye siempre que se construye App. Sess, Perms, Auth son factories invocadas a demanda. La razón histórica es bundle (cache es "casi siempre necesario") no principio de diseño. Con servicios declarativos, la distinción desaparece: todos los servicios opcionales se declaran o no se declaran. Cero asimetría.

8.4 Código propuesto (round 1)

arts/active-app/services.ts

import type { EngineBus } from '$bus';
import type { ActiveTimers } from '$timer';
import type { EngineLogger } from '$logger';

/**
 * El núcleo siempre presente. Todos los servicios pueden depender de
 * cualquier subset de estas piezas.
 */
export interface CoreServices {
	readonly logger: EngineLogger;
	readonly bus: EngineBus;
	readonly timers: ActiveTimers;
	// readonly orca: EngineOrca;  // cuando v0.0 aterrice
}

export type CoreServiceKey = keyof CoreServices;

export type ServiceInitMode = 'immediate' | 'lazy';

export type ServiceStatus = 'absent' | 'present' | 'failed';

export interface AppServiceFactory<
	TName extends string = string,
	TCoreDeps extends readonly CoreServiceKey[] = readonly CoreServiceKey[],
	TServiceDeps extends readonly string[] = readonly string[],
	TInstance = unknown
> {
	readonly name: TName;
	readonly coreDependencies: TCoreDeps;
	readonly serviceDependencies?: TServiceDeps;
	readonly initMode?: ServiceInitMode;

	create(deps: {
		core: Pick<CoreServices, TCoreDeps[number]>;
		services: Partial<Record<TServiceDeps[number], unknown>>;
	}): TInstance;

	dispose?(instance: TInstance): void | Promise<void>;
}

export type AppServiceSchema = Record<string, AppServiceFactory>;

export type ResolveServiceInstances<TSchema extends AppServiceSchema> = {
	[K in keyof TSchema]: TSchema[K] extends AppServiceFactory<
		string,
		readonly CoreServiceKey[],
		readonly string[],
		infer I
	>
		? I
		: never;
};

arts/active-app/active-app.svelte.ts (refactor)

Más corto que el actual. Toda la lógica "factory por artefacto" desaparece; la responsabilidad pasa al schema.

import { createActiveTimers } from '$timer/active-timers.svelte';
import { createSvelteEngineBus } from '$bus';
import { createEngineLogger } from '$logger/engine-logger';
// import { createEngineOrca } from '$orca';  // cuando v0.0 aterrice

import type {
	ActiveApp,
	ActiveAppOptions,
	ActiveAppBusEvents
} from './types.ts';
import type {
	AppServiceSchema,
	CoreServices,
	ServiceStatus
} from './services.ts';

export function createActiveApp<TSchema extends AppServiceSchema = {}>(
	options: ActiveAppOptions<TSchema> = {} as ActiveAppOptions<TSchema>
): ActiveApp<TSchema> {
	// Núcleo
	const Logger = createEngineLogger(options.logger);
	const Timers = createActiveTimers({ ...options.timers, logger: Logger });
	const Bus = createSvelteEngineBus<ActiveAppBusEvents>({
		...options.bus,
		logger: Logger,
		clock: Timers.clock
	});
	// const Orca = createEngineOrca({ bus: Bus, timers: Timers, logger: Logger });

	const core: CoreServices = { logger: Logger, bus: Bus, timers: Timers };

	// Servicios declarados
	const schema = options.services ?? ({} as TSchema);
	const builders = buildServiceBuilders(schema, core);

	let disposed = false;

	return {
		Logger,
		Bus,
		Timers,
		// Orca,
		...builders.proxies,

		get services() {
			return builders.statusMap();
		},

		dispose() {
			if (disposed) return;
			disposed = true;
			builders.disposeAll();           // servicios primero
			Bus.dispose();                    // núcleo después
			Timers.dispose();
			Logger.dispose();
		}
	} as ActiveApp<TSchema>;
}

interface ServiceBuilders {
	readonly proxies: Record<string, unknown>;
	readonly statusMap: () => Record<string, ServiceStatus>;
	readonly disposeAll: () => void;
}

function buildServiceBuilders(
	schema: AppServiceSchema,
	core: CoreServices
): ServiceBuilders {
	validateSchema(schema);
	const order = topologicalOrder(schema);

	const instances = new Map<string, unknown>();
	const status = new Map<string, ServiceStatus>();
	const failures = new Map<string, unknown>();

	for (const name of order) {
		status.set(name, 'absent');
		const factory = schema[name];
		if (factory.initMode === 'immediate') construct(name);
	}

	function construct(name: string): unknown {
		if (instances.has(name)) return instances.get(name);

		const factory = schema[name];
		const coreSubset = pick(core, factory.coreDependencies);
		const serviceSubset: Record<string, unknown> = {};
		for (const dep of factory.serviceDependencies ?? []) {
			if (schema[dep]) serviceSubset[dep] = construct(dep);
		}

		try {
			const instance = factory.create({ core: coreSubset, services: serviceSubset });
			instances.set(name, instance);
			status.set(name, 'present');
			return instance;
		} catch (error) {
			status.set(name, 'failed');
			failures.set(name, error);
			throw error;
		}
	}

	const proxies: Record<string, unknown> = {};
	for (const name of Object.keys(schema)) {
		Object.defineProperty(proxies, name, {
			get: () => construct(name),
			enumerable: true
		});
	}

	function disposeAll() {
		const built = order.filter((n) => instances.has(n)).reverse();
		for (const name of built) {
			try {
				schema[name].dispose?.(instances.get(name));
			} catch { /* dispose errors do not propagate */ }
		}
		instances.clear();
	}

	return {
		proxies,
		statusMap: () => Object.fromEntries(status),
		disposeAll
	};
}

function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
	const result = {} as Pick<T, K>;
	for (const k of keys) result[k] = obj[k];
	return result;
}

function validateSchema(schema: AppServiceSchema): void {
	for (const [key, factory] of Object.entries(schema)) {
		if (factory.name !== key) {
			throw new Error(
				`[active-app] service factory name "${factory.name}" must match schema key "${key}"`
			);
		}
	}
}

function topologicalOrder(schema: AppServiceSchema): string[] {
	const visited = new Set<string>();
	const visiting = new Set<string>();
	const order: string[] = [];

	function visit(name: string) {
		if (visited.has(name)) return;
		if (visiting.has(name)) {
			throw new Error(`[active-app] dependency cycle detected at "${name}"`);
		}
		visiting.add(name);
		const factory = schema[name];
		if (factory) {
			for (const dep of factory.serviceDependencies ?? []) {
				if (schema[dep]) visit(dep);
			}
		}
		visiting.delete(name);
		visited.add(name);
		order.push(name);
	}

	for (const name of Object.keys(schema)) visit(name);
	return order;
}

arts/active-app/types.ts (refactor)

import type { EngineBus, EngineBusOptions } from '$bus';
import type { EngineLogger, LoggerOptions } from '$logger';
import type { ActiveTimers, EngineTimersOptions } from '$timer';
// import type { EngineOrca } from '$orca';  // cuando v0.0 aterrice
import type { ActiveAppBusEvents } from './bus-events';
import type {
	AppServiceSchema,
	ResolveServiceInstances,
	ServiceStatus
} from './services.ts';

export interface ActiveAppOptions<TSchema extends AppServiceSchema = {}> {
	logger?: LoggerOptions;
	timers?: Omit<EngineTimersOptions, 'logger'>;
	bus?: Omit<EngineBusOptions, 'logger' | 'clock'>;
	services?: TSchema;
}

export interface ActiveAppCore {
	readonly Logger: EngineLogger;
	readonly Bus: EngineBus<ActiveAppBusEvents>;
	readonly Timers: ActiveTimers;
	// readonly Orca: EngineOrca;
}

export type ActiveApp<TSchema extends AppServiceSchema = {}> = ActiveAppCore &
	ResolveServiceInstances<TSchema> & {
		readonly services: Record<string, ServiceStatus>;
		dispose(): void;
	};

8.5 Decisiones a las preguntas abiertas (round 1)

Pregunta Decisión
¿Bus/Timers/Logger siempre construidos? SÍ. Coste despreciable. SSR mínimo paga ~50ns y gana coherencia.
¿Big-bang vs coexistencia de APIs? Big-bang en rama dedicada. Coexistencia genera dos APIs vivas que confunden.
¿services: TSchema opcional? SÍ, default {}. createActiveApp() zero-arg sigue siendo válido para tests y SSR mínimo.

8.6 Lo que desaparece del código actual con el refactor (round 1)

  • wireSessionTranslator — pasa a preset.
  • loadPersistedFrontendPreferences y bindFrontendStorage — se mueven al factory defineActiveFrontend. Viven con el artefacto, no en aapp.
  • createAuthCacheInvalidator — pasa a preset opt-in.
  • Flags Sess !== undefined y errores APP_ERROR_ALREADY_CREATED_* — el modelo declarativo garantiza singleton por construcción.
  • Sistema de presets APP_ORCHESTRATION_STANDARD/SILENT/TRANSLATORS — se vuelven funciones explícitas opt-in (§9).

9. Revisión arquitectónica recibida — round 2

Después del round 1 emergen dos refinamientos críticos. El primero cambia cuándo se hace el refactor; el segundo cambia dónde vive cada pieza.

9.1 Construir orca v0.0 ANTES del refactor de aapp

Argumento decisivo: si refactorizas aapp sin orca, los presets se escriben con Bus.on() directo. Cuando llegue orca, hay que reescribirlos como acciones registradas con stages, tokens, políticas. Esa segunda iteración no es migración trivial — es repensar cada flujo crítico (qué stage, qué tokens, qué política de fallo). Se toman las mismas decisiones de diseño dos veces, divergen, y los presets Bus.on quedan como código heredado que "funciona" hasta que alguien tenga tiempo de migrarlos. Spoiler: ese tiempo no llega.

Construir orca v0.0 ahora —aunque con motor mínimo— fija el modelo mental antes de escribir un solo preset. Cuando avances a v0.1 con tokens activos, los presets ya tienen la forma correcta y solo ganan capacidades.

9.2 Orca v0.0: superficie completa, motor mínimo

La regla operativa:

Toda la API pública del orca futuro existe en v0.0. Internamente, las características avanzadas son no-ops o se reducen al caso simple.

Entra en v0.0:

  • createEngineOrca({ bus, timers, logger }) — constructor real.
  • onEvent(event, action) — registro funcional con detach.
  • Action interface completo (id, stage, action, onError, after, unless, abortOn, provides, actionTimeoutMs, compensate).
  • Result types: orcaSuccess, orcaSkipped, orcaError (los tres mínimos producidos; orcaTimeout y orcaFatal definidos pero no producidos).
  • OrcaRunResult con trace básico.
  • dispose() idempotente.
  • Diagnostics catalogados via logr (RUN_STARTED, RUN_COMPLETED, ACTION_STARTED, ACTION_FAILED, ACTION_COMPLETED, ACTION_SKIPPED, RUN_ABORTED, CONFIGURATION_INVALID).

Reducido a versión simple en v0.0:

  • Stages: existen como concepto, motor ejecuta en orden canónico, y dentro de cada stage en orden de registro. Sin paralelismo, sin priority.
  • Tokens (after/provides/unless/abortOn): aceptados en el action interface pero ignorados por el motor. Los presets pueden declararlos correctamente para forward-compat.
  • Políticas de error: solo CONTINUE y ABORT_RUN distinguen. Las demás se aceptan en el tipo y se tratan como CONTINUE.
  • Timeouts: aceptados en el action interface, ignorados por el motor. Acciones que cuelgan, cuelgan. Documentado como limitación temporal.
  • Concurrencia entre runs: solo modo QUEUE implícito. Eventos recibidos durante un run se encolan FIFO.
  • Compensación: campo compensate aceptado, motor no lo invoca.
  • Validación estática del grafo: solo IDs duplicados. Ciclos de tokens, deadlocks, etc., para v0.1.

No entra en v0.0:

  • ActiveOrca (capa reactiva).
  • /test/orca con visualización del DAG.
  • Replay determinista.
  • Modos REPLACE / DROP / PARALLEL.
  • Helper setupOrca({ tokens, events, actions }) con tipado estricto.

Resultado: motor de ~400-600 líneas con contrato exterior indistinguible del orca completo. Las acciones que se escriban hoy funcionan el día que orca esté completo, sin cambios.

9.3 Cambio crítico: presets y factories viven en arts/active-app/

Esto corrige una recomendación inicial errónea de §3.

La idea inicial fue "los presets viven cerca del owner semántico" — por ejemplo arts/cache/presets/invalidate-on-identity-change.ts. Eso es incorrecto porque reintroduce el acoplamiento que estamos eliminando: si el preset vive en arts/cache/, entonces arts/cache/ importa SESSION_EVENT_LIFECYCLE_* desde $session y constantes de orca desde $orca. Acoplamiento de art a art vuelve por la puerta de atrás justo cuando lo estamos sacando por la principal.

La regla correcta:

Las artes no conocen a otras artes. No importan tipos, ni eventos, ni constantes de otras artes. Solo dependen del núcleo (logger, bus, timers, orca) y de utilidades en libs/.

Los presets de orquestación viven en arts/active-app/presets/ porque por definición conocen múltiples artes. Son código de composición, no de artefacto.

Los define*() factories viven en arts/active-app/service-factories/ por la misma razón: importan de las artes (createActiveCache desde $cache) y del active-app (AppServiceFactory desde ./services). Si vivieran en arts/cache/, ese módulo importaría AppServiceFactory desde $active-app y se rompería la inversión de dependencias.

Consecuencia: los arts (arts/cache/index.ts, arts/session/index.ts, etc.) quedan puros. Solo exportan motor + Active reactivo + tipos propios. Cero conocimiento de composición. Cero conocimiento de App.

9.4 App.Orca (no App.Orchestration)

Coherente con:

  • El namespace del núcleo: Logger, Bus, Timers, Orca. Cadencia visual mantenida.
  • El alias del import: $orca. Que el campo de App se llame distinto al artefacto sería ruido innecesario.
  • El propio arts/orca/README.md que documenta el artefacto como "Orca".

La objeción "Orca no es autoexplicativo para alguien que no conoce el sistema" no aplica: nadie sabe qué es Bus o Format sin leer la documentación. La consistencia interna sí es un objetivo realista; la autoexplicación al primer vistazo no.

9.5 La matización sobre paquetes externos

Si en el futuro algún art se distribuye como paquete npm reusable, querría exportar sus propios presets. Para v0 esto no aplica: todos los presets en arts/active-app/presets/. Si llega ese caso, decidiremos entonces. No premature factoring.


10. Plan revisado de implementación

Paso Trabajo Tiempo estimado
1 Implementar arts/orca/ v0.0 (motor + tests). ~1 semana
2 Refactor aapp con modelo de servicios + App.Orca siempre presente. ~3 días
3 Reescribir traductores actuales como presets en arts/active-app/presets/. Eliminar wireSessionTranslator, createAuthCacheInvalidator, APP_ORCHESTRATION_*. ~1 semana
4 Migrar páginas (web/routes/*) al schema declarativo. Iterativo. variable

El paso 1 es lo que destraba todo. Sin él, el paso 3 no tiene un sitio donde aterrizar (los presets no pueden escribirse sobre la API final).


11. Decisiones cerradas (consolidado)

  1. Modelo: núcleo (siempre presente: Logger/Bus/Timers/Orca) + servicios (opt-in declarado).
  2. Naming: createActiveApp(options). defineActiveX / defineEngineX como helpers. App.Orca como campo del núcleo.
  3. Tipo del schema: services: { [K in TName]: AppServiceFactory<...> } con K literal para inferencia.
  4. Servicios declarados que no existen como factory: error.
  5. Dependencias faltantes: error en compile-time (cuando los tipos alcanzan) y en commit() runtime como respaldo.
  6. Init mode por servicio: lazy por defecto. immediate solo si el define*() lo declara así o el schema lo override.
  7. Núcleo configurable vía opciones existentes (lang, storage, frontend, …) en la raíz del options object.
  8. Orquestación: vive en orca. Los presets reusables en arts/active-app/presets/.
  9. Eventos APP_*: sobreviven solo DISPOSE_STARTING. El resto se elimina.
  10. session-translator: se elimina. Pasa a preset applySessionRepublishIdentity.
  11. Presets viven en arts/active-app/presets/, no en cada art.
  12. define*() factories viven en arts/active-app/service-factories/.
  13. Bus, Timers, Logger, Orca siempre construidos — núcleo, no opcional.
  14. Big-bang en rama dedicada, no coexistencia de APIs.
  15. services: TSchema opcional con default {}.
  16. Orca v0.0 antes del refactor de aapp. Pre-requisito.
  17. Singleton uniforme para todos los servicios. connections pasa a singleton (rompiendo la asimetría actual con Set<ActiveConnections>).

Apéndice D — Estructura final del directorio

arts/

src/arts/
├── orca/                           ← motor de orquestación (artefacto puro)
│   ├── README.md
│   ├── consts.ts
│   ├── errors.ts
│   ├── types.ts
│   ├── result.ts
│   ├── engine-orca.ts
│   ├── diagnostics.ts
│   ├── index.ts
│   └── test/
├── cache/                          ← motor puro, no conoce a nadie
├── session/                        ← motor puro
├── auth/                           ← motor puro
├── perm/                           ← motor puro
├── connection/                     ← motor puro
├── lang/, logger/, timer/, format/, frontend/, dom/, sium/, storage/, http/, bus/
└── active-app/                     ← composición (conoce a todos)
    ├── README.md
    ├── refactorizacion.md
    ├── consts.ts
    ├── errors.ts
    ├── types.ts
    ├── services.ts                 ← AppServiceFactory, CoreServices
    ├── service-builder.ts          ← topología, lazy proxies, dispose
    ├── active-app.svelte.ts        ← createActiveApp()
    ├── bus-context.svelte.ts
    ├── integrations/               ← frontend-storage, etc.
    ├── service-factories/          ← define*() para cada art
    │   ├── index.ts
    │   ├── lang.ts
    │   ├── storage.ts
    │   ├── format.ts
    │   ├── frontend.ts
    │   ├── dom.ts
    │   ├── http.ts
    │   ├── cache.ts
    │   ├── session.ts
    │   ├── perm.ts
    │   ├── auth.ts
    │   └── connections.ts
    └── presets/                    ← orquestación reusable opt-in
        ├── index.ts                ← applyStandardOrca + named exports
        ├── session-republish-identity.ts
        ├── cache-invalidate-on-identity-change.ts
        ├── perm-invalidate-on-identity-change.ts
        ├── auth-invalidate-cache-on-revoke.ts
        ├── connection-republish-connectivity.ts
        └── _shared/
            └── tokens.ts

Cómo queda un art puro (ejemplo arts/cache/index.ts)

Después del refactor, cero conocimiento de App:

export {
	createEngineCache,
	createActiveCache
} from './active-cache.svelte';

export type {
	EngineCache,
	ActiveCache,
	ActiveCacheOptions,
	CacheSnapshot,
	CacheEntry,
	CacheError
} from './types';

export { CACHE_DIAGNOSTIC_EVENTS } from './consts';
export {
	CacheDisposedError,
	CacheInvalidScopeError,
	isCacheDisposedError
} from './errors';

Cómo queda un factory (ejemplo arts/active-app/service-factories/cache.ts)

import { createActiveCache, type ActiveCache, type ActiveCacheOptions } from '$cache';
import type { AppServiceFactory } from '../services';

export function defineActiveCache(
	options: Omit<ActiveCacheOptions, 'logger' | 'bus'> = {}
): AppServiceFactory<'cache', ['logger', 'bus'], [], ActiveCache> {
	return {
		name: 'cache',
		coreDependencies: ['logger', 'bus'],
		initMode: 'lazy',
		create({ core }) {
			return createActiveCache({
				...options,
				logger: core.logger,
				bus: core.bus
			});
		},
		dispose(instance) {
			instance.dispose();
		}
	};
}

Cómo queda un preset (ejemplo arts/active-app/presets/cache-invalidate-on-identity-change.ts)

import { SESSION_EVENT_LIFECYCLE_REVOKED, SESSION_EVENT_LIFECYCLE_ADOPTED } from '$session';
import { ORCA_STAGE_MAIN, ORCA_ON_ERROR_CONTINUE, orcaSuccess, orcaError } from '$orca';
import type { ActiveApp } from '../types';

const ACTION_ID = 'cache.invalidate-on-identity-change';
const TOKEN_INVALIDATED = 'cache:invalidated-on-identity';

export function applyCacheInvalidateOnIdentityChange(
	App: ActiveApp & { cache: { invalidate(opts: { scope?: string }): Promise<void> } }
): () => void {
	const detach1 = App.Orca.onEvent(SESSION_EVENT_LIFECYCLE_REVOKED, {
		id: `${ACTION_ID}.revoked`,
		stage: ORCA_STAGE_MAIN,
		provides: [TOKEN_INVALIDATED],
		onError: ORCA_ON_ERROR_CONTINUE,
		action: async (payload) => {
			try {
				await App.cache.invalidate({ scope: payload.previousActorId });
				return orcaSuccess({ emits: [TOKEN_INVALIDATED] });
			} catch (error) {
				return orcaError(error);
			}
		}
	});

	const detach2 = App.Orca.onEvent(SESSION_EVENT_LIFECYCLE_ADOPTED, {
		id: `${ACTION_ID}.adopted`,
		stage: ORCA_STAGE_MAIN,
		provides: [TOKEN_INVALIDATED],
		onError: ORCA_ON_ERROR_CONTINUE,
		action: async (payload) => {
			if (!payload.previousActorId) return orcaSuccess();
			try {
				await App.cache.invalidate({ scope: payload.previousActorId });
				return orcaSuccess({ emits: [TOKEN_INVALIDATED] });
			} catch (error) {
				return orcaError(error);
			}
		}
	});

	return () => {
		detach1();
		detach2();
	};
}

Aggregator estándar (ejemplo arts/active-app/presets/index.ts)

import type { ActiveApp } from '../types';
import { applySessionRepublishIdentity } from './session-republish-identity';
import { applyCacheInvalidateOnIdentityChange } from './cache-invalidate-on-identity-change';
import { applyPermInvalidateOnIdentityChange } from './perm-invalidate-on-identity-change';
import { applyAuthInvalidateCacheOnRevoke } from './auth-invalidate-cache-on-revoke';
import { applyConnectionRepublishConnectivity } from './connection-republish-connectivity';

export {
	applySessionRepublishIdentity,
	applyCacheInvalidateOnIdentityChange,
	applyPermInvalidateOnIdentityChange,
	applyAuthInvalidateCacheOnRevoke,
	applyConnectionRepublishConnectivity
};

export function applyStandardOrca(App: ActiveApp): () => void {
	const detachers: Array<() => void> = [];

	if ('session' in App) detachers.push(applySessionRepublishIdentity(App as never));
	if ('cache' in App) detachers.push(applyCacheInvalidateOnIdentityChange(App as never));
	if ('perm' in App) detachers.push(applyPermInvalidateOnIdentityChange(App as never));
	if ('auth' in App && 'cache' in App) detachers.push(applyAuthInvalidateCacheOnRevoke(App as never));
	if ('connections' in App) detachers.push(applyConnectionRepublishConnectivity(App as never));

	return () => {
		for (const detach of detachers.reverse()) detach();
	};
}

Uso final desde la app

import { createActiveApp } from '$active-app';
import {
	defineActiveLang,
	defineActiveCache,
	defineActiveSession,
	defineEngineHttp
} from '$active-app/service-factories';
import { applyStandardOrca } from '$active-app/presets';

export const App = createActiveApp({
	logger: { level: LogLevel.INFO },
	services: {
		lang: defineActiveLang({ schema, defaultLocale: 'es' }),
		cache: defineActiveCache({ adapter: 'memory' }),
		session: defineActiveSession<MyUser>({ onRefresh, onRevoke }),
		http: defineEngineHttp({ baseUrl: '/api' })
	}
});

applyStandardOrca(App);

La aplicación importa todo de $active-app/*. Los arts no aparecen en sus imports.


Apéndice E — Contrato de EngineOrca v0.0

Versión completa del contrato y la implementación mínima del motor. La disciplina aplicada es superficie completa, motor mínimo: campos @v0.0 se honran, campos @v0.1+ se aceptan en los tipos pero el motor no actúa sobre ellos.

E.1 arts/orca/consts.ts

export const ORCA_MODULE = 'orca' as const;

// ── Stages ────────────────────────────────────────────────────────

export const ORCA_STAGE_GUARD = 'guard' as const;
export const ORCA_STAGE_PRE = 'pre' as const;
export const ORCA_STAGE_MAIN = 'main' as const;
export const ORCA_STAGE_POST = 'post' as const;
export const ORCA_STAGE_CLEANUP = 'cleanup' as const;
export const ORCA_STAGE_FINALLY = 'finally' as const;

export const ORCA_STAGES_CANONICAL_ORDER = [
	ORCA_STAGE_GUARD,
	ORCA_STAGE_PRE,
	ORCA_STAGE_MAIN,
	ORCA_STAGE_POST,
	ORCA_STAGE_CLEANUP,
	ORCA_STAGE_FINALLY
] as const;

// ── Result statuses ───────────────────────────────────────────────

export const ORCA_RESULT_SUCCESS = 'success' as const;
export const ORCA_RESULT_SKIPPED = 'skipped' as const;
export const ORCA_RESULT_ERROR = 'error' as const;
export const ORCA_RESULT_TIMEOUT = 'timeout' as const; // aceptado, no producido en v0.0
export const ORCA_RESULT_FATAL = 'fatal' as const;     // aceptado, no producido en v0.0

// ── Action statuses (en el run trace) ─────────────────────────────

export const ORCA_ACTION_STATUS_SUCCESS = 'success' as const;
export const ORCA_ACTION_STATUS_SKIPPED = 'skipped' as const;
export const ORCA_ACTION_STATUS_BLOCKED = 'blocked' as const;
export const ORCA_ACTION_STATUS_ERROR = 'error' as const;
export const ORCA_ACTION_STATUS_TIMEOUT = 'timeout' as const;
export const ORCA_ACTION_STATUS_FATAL = 'fatal' as const;

// ── Run statuses ──────────────────────────────────────────────────

export const ORCA_RUN_SUCCESS = 'success' as const;
export const ORCA_RUN_PARTIAL = 'partial' as const;
export const ORCA_RUN_ABORTED = 'aborted' as const;
export const ORCA_RUN_FATAL = 'fatal' as const;
export const ORCA_RUN_TIMEOUT = 'timeout' as const;

// ── Error policies ────────────────────────────────────────────────
// v0.0 implementa CONTINUE y ABORT_RUN. El resto se acepta y se trata
// como CONTINUE.

export const ORCA_ON_ERROR_CONTINUE = 'continue' as const;
export const ORCA_ON_ERROR_ABORT_ACTION = 'abort-action' as const;
export const ORCA_ON_ERROR_ABORT_STAGE = 'abort-stage' as const;
export const ORCA_ON_ERROR_ABORT_RUN = 'abort-run' as const;

// ── Diagnostic event names ────────────────────────────────────────

export const ORCA_DIAGNOSTIC_EVENTS = {
	RUN_STARTED: 'orca.run.started',
	RUN_COMPLETED: 'orca.run.completed',
	RUN_ABORTED: 'orca.run.aborted',
	ACTION_STARTED: 'orca.action.started',
	ACTION_COMPLETED: 'orca.action.completed',
	ACTION_FAILED: 'orca.action.failed',
	ACTION_SKIPPED: 'orca.action.skipped',
	CONFIGURATION_INVALID: 'orca.configuration.invalid'
} as const;

export const LOGGER_CATEGORY = 'orca' as const;

E.2 arts/orca/errors.ts

import { CodeError, errCode, type ErrCode, type ErrorMessages } from '$libs/errs';

const ORCA_ERR = 'orca' as const;

export const ORCA_ERR_DISPOSED: ErrCode = errCode(ORCA_ERR, 'disposed');
export const ORCA_ERR_DUPLICATE_ACTION_ID: ErrCode = errCode(ORCA_ERR, 'duplicate_action_id');
export const ORCA_ERR_INVALID_STAGE: ErrCode = errCode(ORCA_ERR, 'invalid_stage');
export const ORCA_ERR_INVALID_ACTION: ErrCode = errCode(ORCA_ERR, 'invalid_action');

export const ORCA_ERROR_MESSAGES: ErrorMessages = {
	[ORCA_ERR_DISPOSED]: 'Orca engine has been disposed.',
	[ORCA_ERR_DUPLICATE_ACTION_ID]: 'Action id already registered for this event.',
	[ORCA_ERR_INVALID_STAGE]: 'Action declared with unknown stage.',
	[ORCA_ERR_INVALID_ACTION]: 'Action definition is missing required fields.'
};

export class OrcaDisposedError extends CodeError {
	constructor(message?: string) {
		super(ORCA_ERR_DISPOSED, { message: message ?? ORCA_ERROR_MESSAGES[ORCA_ERR_DISPOSED] });
	}
}

export class OrcaDuplicateActionIdError extends CodeError {
	constructor(event: string, actionId: string) {
		super(ORCA_ERR_DUPLICATE_ACTION_ID, {
			message: `Action "${actionId}" already registered for event "${event}".`
		});
	}
}

export class OrcaInvalidStageError extends CodeError {
	constructor(stage: string) {
		super(ORCA_ERR_INVALID_STAGE, {
			message: `Unknown stage "${stage}". Valid stages: guard, pre, main, post, cleanup, finally.`
		});
	}
}

export class OrcaInvalidActionError extends CodeError {
	constructor(reason: string) {
		super(ORCA_ERR_INVALID_ACTION, {
			message: `Invalid action: ${reason}`
		});
	}
}

export function isOrcaDisposedError(error: unknown): error is OrcaDisposedError {
	return error instanceof OrcaDisposedError;
}

E.3 arts/orca/types.ts

Contrato exterior completo. Marcas @v0.0 y @v0.1+ indican qué se honra y qué se acepta-pero-ignora.

import type { EngineBus } from '$bus';
import type { ActiveTimers } from '$timer';
import type { Logger } from '$libs/logr';
import type {
	ORCA_STAGE_GUARD,
	ORCA_STAGE_PRE,
	ORCA_STAGE_MAIN,
	ORCA_STAGE_POST,
	ORCA_STAGE_CLEANUP,
	ORCA_STAGE_FINALLY,
	ORCA_RESULT_SUCCESS,
	ORCA_RESULT_SKIPPED,
	ORCA_RESULT_ERROR,
	ORCA_RESULT_TIMEOUT,
	ORCA_RESULT_FATAL,
	ORCA_ACTION_STATUS_SUCCESS,
	ORCA_ACTION_STATUS_SKIPPED,
	ORCA_ACTION_STATUS_BLOCKED,
	ORCA_ACTION_STATUS_ERROR,
	ORCA_ACTION_STATUS_TIMEOUT,
	ORCA_ACTION_STATUS_FATAL,
	ORCA_RUN_SUCCESS,
	ORCA_RUN_PARTIAL,
	ORCA_RUN_ABORTED,
	ORCA_RUN_FATAL,
	ORCA_RUN_TIMEOUT,
	ORCA_ON_ERROR_CONTINUE,
	ORCA_ON_ERROR_ABORT_ACTION,
	ORCA_ON_ERROR_ABORT_STAGE,
	ORCA_ON_ERROR_ABORT_RUN
} from './consts';

// ── Identifiers ───────────────────────────────────────────────────

export type OrcaStage =
	| typeof ORCA_STAGE_GUARD
	| typeof ORCA_STAGE_PRE
	| typeof ORCA_STAGE_MAIN
	| typeof ORCA_STAGE_POST
	| typeof ORCA_STAGE_CLEANUP
	| typeof ORCA_STAGE_FINALLY;

export type OrcaToken = string;
export type OrcaActionId = string;
export type OrcaRunId = string;

export type OrcaErrorPolicy =
	| typeof ORCA_ON_ERROR_CONTINUE
	| typeof ORCA_ON_ERROR_ABORT_ACTION
	| typeof ORCA_ON_ERROR_ABORT_STAGE
	| typeof ORCA_ON_ERROR_ABORT_RUN;

// ── Result types ──────────────────────────────────────────────────

export interface OrcaSuccess<TValue = unknown> {
	readonly ok: true;
	readonly status: typeof ORCA_RESULT_SUCCESS;
	readonly value?: TValue;
	readonly emits?: readonly OrcaToken[];
}

export interface OrcaSkipped {
	readonly ok: true;
	readonly status: typeof ORCA_RESULT_SKIPPED;
	readonly reason?: string;
	readonly emits?: readonly OrcaToken[];
}

export interface OrcaError {
	readonly ok: false;
	readonly status: typeof ORCA_RESULT_ERROR;
	readonly error: unknown;
	readonly recoverable?: boolean;
	readonly emits?: readonly OrcaToken[];
}

/**
 * @v0.1+ Returned by the engine when an action exceeds `actionTimeoutMs`.
 * @v0.0 The shape exists so action authors can type results, but the engine
 *       never produces one (timeouts are not enforced).
 */
export interface OrcaTimeout {
	readonly ok: false;
	readonly status: typeof ORCA_RESULT_TIMEOUT;
	readonly timeoutMs: number;
	readonly emits?: readonly OrcaToken[];
}

/**
 * @v0.1+ Distinguished from `OrcaError` for run-aborting failures.
 * @v0.0 Engine treats fatal as error.
 */
export interface OrcaFatal {
	readonly ok: false;
	readonly status: typeof ORCA_RESULT_FATAL;
	readonly error: unknown;
	readonly emits?: readonly OrcaToken[];
}

export type OrcaResult<TValue = unknown> =
	| OrcaSuccess<TValue>
	| OrcaSkipped
	| OrcaError
	| OrcaTimeout
	| OrcaFatal;

// ── Action context ────────────────────────────────────────────────

export interface OrcaActionContext {
	readonly runId: OrcaRunId;
	readonly event: string;
	readonly stage: OrcaStage;
	/**
	 * Tokens already emitted in the current run by previous actions.
	 * @v0.0 The set is populated correctly, but not consumed by the engine
	 *       (after/unless/abortOn are ignored). Action authors MAY read it.
	 */
	readonly tokens: ReadonlySet<OrcaToken>;
	/**
	 * Abort signal for the current action.
	 */
	readonly signal: AbortSignal;
	/**
	 * Logger for ad-hoc diagnostics inside the action.
	 */
	readonly logger: Logger;
}

// ── Action definition ─────────────────────────────────────────────

export type OrcaActionFn<TPayload = unknown, TValue = unknown> = (
	payload: TPayload,
	context: OrcaActionContext
) => OrcaResult<TValue> | Promise<OrcaResult<TValue>>;

export interface OrcaAction<TPayload = unknown, TValue = unknown> {
	readonly id: OrcaActionId;
	readonly stage: OrcaStage;

	/** @v0.1+ accepted, ignored in v0.0 */
	readonly after?: readonly OrcaToken[];
	/** @v0.1+ accepted, ignored in v0.0 */
	readonly unless?: readonly OrcaToken[];
	/** @v0.1+ accepted, ignored in v0.0 */
	readonly abortOn?: readonly OrcaToken[];

	/** Documented for consumers; engine uses emits in run context regardless. */
	readonly provides?: readonly OrcaToken[];

	/** @v0.1+ accepted, ignored. Long actions hang in v0.0. */
	readonly actionTimeoutMs?: number;

	/** @v0.0 Honored. Only CONTINUE and ABORT_RUN distinct. */
	readonly onError?: OrcaErrorPolicy;

	/** @v1+ accepted, never invoked in v0.0. */
	readonly compensate?: OrcaActionFn<TPayload, void>;

	readonly action: OrcaActionFn<TPayload, TValue>;
}

// ── Run trace ─────────────────────────────────────────────────────

export interface OrcaActionRun {
	readonly id: OrcaActionId;
	readonly stage: OrcaStage;
	readonly status:
		| typeof ORCA_ACTION_STATUS_SUCCESS
		| typeof ORCA_ACTION_STATUS_SKIPPED
		| typeof ORCA_ACTION_STATUS_BLOCKED
		| typeof ORCA_ACTION_STATUS_ERROR
		| typeof ORCA_ACTION_STATUS_TIMEOUT
		| typeof ORCA_ACTION_STATUS_FATAL;
	readonly startedAt?: number;
	readonly endedAt?: number;
	readonly durationMs?: number;
	readonly emitted: readonly OrcaToken[];
	readonly error?: unknown;
}

export interface OrcaRunResult {
	readonly id: OrcaRunId;
	readonly event: string;
	readonly status:
		| typeof ORCA_RUN_SUCCESS
		| typeof ORCA_RUN_PARTIAL
		| typeof ORCA_RUN_ABORTED
		| typeof ORCA_RUN_FATAL
		| typeof ORCA_RUN_TIMEOUT;
	readonly startedAt: number;
	readonly endedAt: number;
	readonly durationMs: number;
	readonly tokens: readonly OrcaToken[];
	readonly actions: readonly OrcaActionRun[];
}

// ── Engine ────────────────────────────────────────────────────────

export interface EngineOrcaOptions {
	readonly bus: EngineBus;
	readonly timers: ActiveTimers;
	readonly logger?: Logger;
	/** @default 256 */
	readonly maxRuns?: number;
}

export interface EngineOrca {
	onEvent<TPayload = unknown, TValue = unknown>(
		event: string,
		action: OrcaAction<TPayload, TValue>
	): () => void;

	actionCount(event: string): number;
	recentRuns(): readonly OrcaRunResult[];
	readonly running: boolean;
	readonly disposed: boolean;
	dispose(): void;
}

E.4 arts/orca/result.ts

Helpers para construir results. Todo el ecosistema los usa en lugar de literales.

import {
	ORCA_RESULT_SUCCESS,
	ORCA_RESULT_SKIPPED,
	ORCA_RESULT_ERROR,
	ORCA_RESULT_TIMEOUT,
	ORCA_RESULT_FATAL
} from './consts';
import type {
	OrcaSuccess,
	OrcaSkipped,
	OrcaError,
	OrcaTimeout,
	OrcaFatal,
	OrcaToken
} from './types';

export function orcaSuccess<TValue = void>(
	options: { value?: TValue; emits?: readonly OrcaToken[] } = {}
): OrcaSuccess<TValue> {
	return {
		ok: true,
		status: ORCA_RESULT_SUCCESS,
		value: options.value,
		emits: options.emits
	};
}

export function orcaSkipped(
	reason?: string,
	options: { emits?: readonly OrcaToken[] } = {}
): OrcaSkipped {
	return {
		ok: true,
		status: ORCA_RESULT_SKIPPED,
		reason,
		emits: options.emits
	};
}

export function orcaError(
	error: unknown,
	options: { emits?: readonly OrcaToken[]; recoverable?: boolean } = {}
): OrcaError {
	return {
		ok: false,
		status: ORCA_RESULT_ERROR,
		error,
		recoverable: options.recoverable,
		emits: options.emits
	};
}

/** @v0.1+ */
export function orcaTimeout(
	timeoutMs: number,
	options: { emits?: readonly OrcaToken[] } = {}
): OrcaTimeout {
	return {
		ok: false,
		status: ORCA_RESULT_TIMEOUT,
		timeoutMs,
		emits: options.emits
	};
}

/** @v0.1+ */
export function orcaFatal(
	error: unknown,
	options: { emits?: readonly OrcaToken[] } = {}
): OrcaFatal {
	return {
		ok: false,
		status: ORCA_RESULT_FATAL,
		error,
		emits: options.emits
	};
}

E.5 arts/orca/engine-orca.ts — motor v0.0

import {
	ORCA_STAGES_CANONICAL_ORDER,
	ORCA_STAGE_FINALLY,
	ORCA_ON_ERROR_CONTINUE,
	ORCA_ON_ERROR_ABORT_RUN,
	ORCA_RESULT_SUCCESS,
	ORCA_RESULT_SKIPPED,
	ORCA_RESULT_ERROR,
	ORCA_RESULT_FATAL,
	ORCA_ACTION_STATUS_SUCCESS,
	ORCA_ACTION_STATUS_SKIPPED,
	ORCA_ACTION_STATUS_ERROR,
	ORCA_ACTION_STATUS_BLOCKED,
	ORCA_RUN_SUCCESS,
	ORCA_RUN_PARTIAL,
	ORCA_RUN_ABORTED,
	ORCA_DIAGNOSTIC_EVENTS,
	LOGGER_CATEGORY
} from './consts';
import {
	OrcaDisposedError,
	OrcaDuplicateActionIdError,
	OrcaInvalidActionError,
	OrcaInvalidStageError
} from './errors';
import type {
	EngineOrca,
	EngineOrcaOptions,
	OrcaAction,
	OrcaActionContext,
	OrcaActionRun,
	OrcaResult,
	OrcaRunId,
	OrcaRunResult,
	OrcaStage,
	OrcaToken
} from './types';

const DEFAULT_MAX_RUNS = 256;

export function createEngineOrca(options: EngineOrcaOptions): EngineOrca {
	const { bus, timers, logger } = options;
	const maxRuns = options.maxRuns ?? DEFAULT_MAX_RUNS;

	const actionsByEvent = new Map<string, OrcaAction[]>();
	const busDetachers = new Map<string, () => void>();
	const recentRuns: OrcaRunResult[] = [];
	const runQueue: Array<() => Promise<void>> = [];

	let disposed = false;
	let running = false;
	let activeRunController: AbortController | null = null;

	function ensureNotDisposed() {
		if (disposed) throw new OrcaDisposedError();
	}

	function validateAction(action: OrcaAction): void {
		if (!action || typeof action !== 'object') {
			throw new OrcaInvalidActionError('action must be an object');
		}
		if (typeof action.id !== 'string' || action.id.length === 0) {
			throw new OrcaInvalidActionError('action.id must be a non-empty string');
		}
		if (typeof action.action !== 'function') {
			throw new OrcaInvalidActionError('action.action must be a function');
		}
		if (!ORCA_STAGES_CANONICAL_ORDER.includes(action.stage)) {
			throw new OrcaInvalidStageError(action.stage);
		}
	}

	function onEvent<TPayload, TValue>(
		event: string,
		action: OrcaAction<TPayload, TValue>
	): () => void {
		ensureNotDisposed();
		validateAction(action);

		const list = actionsByEvent.get(event) ?? [];

		if (list.some((a) => a.id === action.id)) {
			throw new OrcaDuplicateActionIdError(event, action.id);
		}

		list.push(action as OrcaAction);
		actionsByEvent.set(event, list);

		// Suscripción lazy al bus: solo cuando se registra la primera acción.
		if (!busDetachers.has(event)) {
			const detach = bus.on(event, (payload: unknown) => {
				enqueueRun(event, payload);
			});
			busDetachers.set(event, detach);
		}

		return () => {
			const current = actionsByEvent.get(event);
			if (!current) return;
			const filtered = current.filter((a) => a.id !== action.id);
			if (filtered.length === 0) {
				actionsByEvent.delete(event);
				busDetachers.get(event)?.();
				busDetachers.delete(event);
			} else {
				actionsByEvent.set(event, filtered);
			}
		};
	}

	function enqueueRun(event: string, payload: unknown): void {
		if (disposed) return;
		const actions = actionsByEvent.get(event);
		if (!actions || actions.length === 0) return;

		// Snapshot: acciones registradas durante un run no participan en él.
		const snapshot = actions.slice();

		runQueue.push(() => executeRun(event, payload, snapshot));
		drainQueue();
	}

	async function drainQueue(): Promise<void> {
		if (running || disposed) return;
		const next = runQueue.shift();
		if (!next) return;

		running = true;
		try {
			await next();
		} finally {
			running = false;
			if (!disposed && runQueue.length > 0) {
				queueMicrotask(() => drainQueue());
			}
		}
	}

	async function executeRun(
		event: string,
		payload: unknown,
		actions: OrcaAction[]
	): Promise<void> {
		const runId = generateRunId();
		const startedAt = timers.clock.now();
		const tokens = new Set<OrcaToken>();
		const actionRuns: OrcaActionRun[] = [];
		const controller = new AbortController();
		activeRunController = controller;

		emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.RUN_STARTED, { runId, event });

		let aborted = false;
		const byStage = groupByStage(actions);

		for (const stage of ORCA_STAGES_CANONICAL_ORDER) {
			const stageActions = byStage.get(stage);
			if (!stageActions || stageActions.length === 0) continue;

			// FINALLY siempre se ejecuta, incluso tras abort.
			if (aborted && stage !== ORCA_STAGE_FINALLY) continue;

			for (const action of stageActions) {
				if (controller.signal.aborted) break;

				const actionRun = await runAction({
					action,
					payload,
					context: {
						runId,
						event,
						stage,
						tokens,
						signal: controller.signal,
						logger: logger ?? noopLogger()
					},
					tokens
				});

				actionRuns.push(actionRun);

				if (actionRun.status === ORCA_ACTION_STATUS_ERROR) {
					const policy = action.onError ?? ORCA_ON_ERROR_CONTINUE;
					if (policy === ORCA_ON_ERROR_ABORT_RUN) {
						aborted = true;
						controller.abort();
						emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.RUN_ABORTED, {
							runId,
							event,
							cause: actionRun.error
						});
						break;
					}
				}
			}
		}

		const endedAt = timers.clock.now();
		const status = computeRunStatus(aborted, actionRuns);

		const runResult: OrcaRunResult = {
			id: runId,
			event,
			status,
			startedAt,
			endedAt,
			durationMs: endedAt - startedAt,
			tokens: Array.from(tokens),
			actions: actionRuns
		};

		recordRun(runResult);
		activeRunController = null;

		emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.RUN_COMPLETED, {
			runId,
			event,
			status,
			durationMs: runResult.durationMs,
			actionCount: actionRuns.length
		});
	}

	async function runAction(opts: {
		action: OrcaAction;
		payload: unknown;
		context: OrcaActionContext;
		tokens: Set<OrcaToken>;
	}): Promise<OrcaActionRun> {
		const { action, payload, context, tokens } = opts;
		const startedAt = timers.clock.now();

		emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_STARTED, {
			runId: context.runId,
			actionId: action.id,
			stage: action.stage
		});

		if (context.signal.aborted) {
			return {
				id: action.id,
				stage: action.stage,
				status: ORCA_ACTION_STATUS_BLOCKED,
				startedAt,
				endedAt: startedAt,
				durationMs: 0,
				emitted: []
			};
		}

		try {
			const result = await action.action(payload, context);
			const endedAt = timers.clock.now();
			const emitted = result.emits ?? [];

			for (const token of emitted) tokens.add(token);

			const status = mapResultToActionStatus(result);

			if (status === ORCA_ACTION_STATUS_ERROR) {
				emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_FAILED, {
					runId: context.runId,
					actionId: action.id,
					error: (result as { error?: unknown }).error
				});
			} else if (status === ORCA_ACTION_STATUS_SKIPPED) {
				emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_SKIPPED, {
					runId: context.runId,
					actionId: action.id,
					reason: (result as { reason?: string }).reason
				});
			} else {
				emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_COMPLETED, {
					runId: context.runId,
					actionId: action.id,
					durationMs: endedAt - startedAt
				});
			}

			return {
				id: action.id,
				stage: action.stage,
				status,
				startedAt,
				endedAt,
				durationMs: endedAt - startedAt,
				emitted: Array.from(emitted),
				error: status === ORCA_ACTION_STATUS_ERROR
					? (result as { error?: unknown }).error
					: undefined
			};
		} catch (thrown) {
			// Excepciones no capturadas se convierten en error.
			const endedAt = timers.clock.now();
			emitDiagnostic(ORCA_DIAGNOSTIC_EVENTS.ACTION_FAILED, {
				runId: context.runId,
				actionId: action.id,
				error: thrown,
				thrown: true
			});
			return {
				id: action.id,
				stage: action.stage,
				status: ORCA_ACTION_STATUS_ERROR,
				startedAt,
				endedAt,
				durationMs: endedAt - startedAt,
				emitted: [],
				error: thrown
			};
		}
	}

	function recordRun(run: OrcaRunResult): void {
		recentRuns.push(run);
		while (recentRuns.length > maxRuns) recentRuns.shift();
	}

	function emitDiagnostic(event: string, data: Record<string, unknown>): void {
		if (!logger) return;
		logger.debug?.({ category: LOGGER_CATEGORY, message: event, data });
	}

	function dispose(): void {
		if (disposed) return;
		disposed = true;
		activeRunController?.abort();
		for (const detach of busDetachers.values()) detach();
		busDetachers.clear();
		actionsByEvent.clear();
		runQueue.length = 0;
	}

	return {
		onEvent,
		actionCount(event: string) {
			return actionsByEvent.get(event)?.length ?? 0;
		},
		recentRuns() {
			return recentRuns.slice();
		},
		get running() {
			return running;
		},
		get disposed() {
			return disposed;
		},
		dispose
	};
}

// ── Helpers ───────────────────────────────────────────────────────

function groupByStage(actions: OrcaAction[]): Map<OrcaStage, OrcaAction[]> {
	const result = new Map<OrcaStage, OrcaAction[]>();
	for (const action of actions) {
		const list = result.get(action.stage) ?? [];
		list.push(action);
		result.set(action.stage, list);
	}
	return result;
}

function mapResultToActionStatus(result: OrcaResult) {
	switch (result.status) {
		case ORCA_RESULT_SUCCESS:
			return ORCA_ACTION_STATUS_SUCCESS;
		case ORCA_RESULT_SKIPPED:
			return ORCA_ACTION_STATUS_SKIPPED;
		case ORCA_RESULT_ERROR:
			return ORCA_ACTION_STATUS_ERROR;
		case ORCA_RESULT_FATAL:
			return ORCA_ACTION_STATUS_ERROR; // v0.0 trata fatal como error
		default:
			return ORCA_ACTION_STATUS_ERROR; // timeout no se produce en v0.0
	}
}

function computeRunStatus(aborted: boolean, actions: OrcaActionRun[]) {
	if (aborted) return ORCA_RUN_ABORTED;
	const anyError = actions.some(
		(a) => a.status === 'error' || a.status === 'fatal' || a.status === 'timeout'
	);
	if (anyError) return ORCA_RUN_PARTIAL;
	return ORCA_RUN_SUCCESS;
}

function generateRunId(): OrcaRunId {
	return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

function noopLogger() {
	const noop = () => {};
	return {
		trace: noop,
		debug: noop,
		info: noop,
		warn: noop,
		error: noop,
		fatal: noop
	};
}

E.6 arts/orca/index.ts (barrel)

export { createEngineOrca } from './engine-orca';
export {
	orcaSuccess,
	orcaSkipped,
	orcaError,
	orcaTimeout,
	orcaFatal
} from './result';

export {
	ORCA_MODULE,
	ORCA_STAGE_GUARD,
	ORCA_STAGE_PRE,
	ORCA_STAGE_MAIN,
	ORCA_STAGE_POST,
	ORCA_STAGE_CLEANUP,
	ORCA_STAGE_FINALLY,
	ORCA_STAGES_CANONICAL_ORDER,
	ORCA_RESULT_SUCCESS,
	ORCA_RESULT_SKIPPED,
	ORCA_RESULT_ERROR,
	ORCA_RESULT_TIMEOUT,
	ORCA_RESULT_FATAL,
	ORCA_RUN_SUCCESS,
	ORCA_RUN_PARTIAL,
	ORCA_RUN_ABORTED,
	ORCA_RUN_FATAL,
	ORCA_RUN_TIMEOUT,
	ORCA_ON_ERROR_CONTINUE,
	ORCA_ON_ERROR_ABORT_ACTION,
	ORCA_ON_ERROR_ABORT_STAGE,
	ORCA_ON_ERROR_ABORT_RUN,
	ORCA_DIAGNOSTIC_EVENTS,
	LOGGER_CATEGORY
} from './consts';

export {
	OrcaDisposedError,
	OrcaDuplicateActionIdError,
	OrcaInvalidStageError,
	OrcaInvalidActionError,
	isOrcaDisposedError
} from './errors';

export type {
	EngineOrca,
	EngineOrcaOptions,
	OrcaAction,
	OrcaActionFn,
	OrcaActionContext,
	OrcaActionId,
	OrcaActionRun,
	OrcaError,
	OrcaErrorPolicy,
	OrcaFatal,
	OrcaResult,
	OrcaRunId,
	OrcaRunResult,
	OrcaSkipped,
	OrcaStage,
	OrcaSuccess,
	OrcaTimeout,
	OrcaToken
} from './types';

E.7 Matriz de tests para v0.0

describe('EngineOrca v0.0', () => {
	describe('registration', () => {
		it('registra y elimina acciones por evento');
		it('lanza OrcaDuplicateActionIdError si se registra la misma id dos veces');
		it('lanza OrcaInvalidStageError si el stage es inválido');
		it('lanza OrcaInvalidActionError si falta id o action');
		it('se suscribe al bus al registrar la primera acción del evento');
		it('se desuscribe del bus al eliminar la última acción del evento');
	});

	describe('execution', () => {
		it('ejecuta acciones en orden canónico de stages');
		it('ejecuta acciones del mismo stage en orden de registro');
		it('captura excepciones de acciones como error');
		it('agrega tokens emitidos al run context (aunque no los consume)');
		it('ejecuta finally aunque el run haya sido abortado');
		it('no ejecuta acciones registradas durante un run en ese mismo run');
	});

	describe('error policies', () => {
		it('continúa con onError: CONTINUE');
		it('aborta el run con onError: ABORT_RUN');
		it('trata ABORT_ACTION y ABORT_STAGE como CONTINUE en v0.0');
	});

	describe('concurrency', () => {
		it('encola eventos del mismo tipo durante un run en vuelo');
		it('procesa eventos encolados en orden FIFO');
	});

	describe('run trace', () => {
		it('produce OrcaRunResult con startedAt/endedAt/durationMs');
		it('lista todas las acciones ejecutadas con su status');
		it('respeta maxRuns en recentRuns()');
	});

	describe('disposal', () => {
		it('dispose() es idempotente');
		it('aborta el run en vuelo al disponer');
		it('lanza OrcaDisposedError al registrar tras dispose');
		it('eventos del bus tras dispose no ejecutan acciones');
	});

	describe('v0.1+ accepted-but-ignored fields', () => {
		it('acepta after sin esperar tokens');
		it('acepta unless sin saltar acciones');
		it('acepta abortOn sin bloquear');
		it('acepta actionTimeoutMs sin enforcer timeout');
		it('acepta compensate sin invocarlo');
	});
});

E.8 Lo que el motor v0.0 NO hace (resumen claro)

Para que el README de orca pueda referenciarlo:

  • No espera tokens (after ignorado).
  • No salta acciones por tokens presentes (unless ignorado).
  • No bloquea acciones por tokens (abortOn ignorado).
  • No respeta timeouts (actionTimeoutMs ignorado).
  • No invoca compensaciones (compensate ignorado).
  • No detecta deadlocks de tokens.
  • No expone ActiveOrca (la capa reactiva).
  • No tiene modos de concurrencia configurables — siempre QUEUE.
  • No distingue ABORT_ACTION ni ABORT_STAGE — todos son CONTINUE excepto ABORT_RUN.

Lo que sí garantiza: un preset bien escrito en v0.0 sigue funcionando correctamente en v0.1+, ganando capacidades sin reescritura. Esa es la propiedad de diseño que justifica este enfoque.


Cambios aplicados durante esta sesión

A medida que se ejecutan las fases, esta sección se actualiza:

  • 2026-05-02 (sesión de diseño):

    • Documento creado (§1–§7): análisis inicial, modelo de servicios, decisiones, plan.
    • §8: revisión round 1 con código concreto.
    • §9: revisión round 2 — orca v0.0 primero, presets y factories en arts/active-app/.
    • §10–§11: plan revisado, decisiones consolidadas (17 items).
    • Apéndices D y E: estructura final + contrato completo de EngineOrca v0.0.
  • 2026-05-02 (sesión larga de implementación):

    • Paso 1 (commit 0eddd2a) — arts/orca/ v0.0 implementado. 9 archivos, +37 tests.
    • Paso 2A (commit d528652) — Contratos services.ts + service-builder.ts + 8 factories puros (lang, storage, format, dom, frontend, http, sium, auth). +23 tests.
    • Paso 2B (commit a04fa67) — 4 factories restantes (cache, perm, session, connections) + 4 presets de orca + agregador applyStandardOrca. +8 tests.
    • Paso 3A (commit fb2e3ac) — App.Orca añadido al núcleo de createActiveApp().
    • Paso 3B (commit 7148a5d) — services: TSchema aceptado en createActiveApp(). App.cache, App.session, etc. (lowercase) accesibles. +6 tests.
    • Paso 3C (commit 60e130b) — APIs legacy marcadas @deprecated con guía de migración (cache/perm options, App.createActiveX(), App.Sess/Perms/Auth, ActiveAppOptions.{connections, permissions, auth, orchestration}, publishers de APP_EVENT_*).
  • Suite total: 1408 tests pasan. Sin regresiones.

Estado actual: BIG-BANG COMPLETADO (commits 01a85ad + 64ab1f0)

El refactor está cerrado. La API legacy ha sido eliminada completamente.

Modelo único soportado en master:

const App = createActiveApp({
  logger: { ... },
  services: {
    cache: defineActiveCache(),
    session: defineActiveSession<MyUser>({ onRefresh, onRevoke }),
    http: defineEngineHttp({ baseUrl: '/api' })
  }
});
applyStandardOrca(App);

App.Orca siempre presente. Servicios construyen lazy. Acceso vía propiedades lowercase (App.cache, App.session).

Eliminaciones aplicadas en el big-bang

Tests legacy (commit 01a85ad)

  • ecosystem.integration.test.ts (9 monolithic, ~1900 líneas).
  • session-translator.test.ts.
  • active-app.test.ts reescrito de 1072 → ~200 líneas con tests enfocados en composición del núcleo.

Código legacy (commit 64ab1f0)

  • arts/active-app/integrations/session-translator.ts y auth-cache.ts.
  • arts/connection/bus-session-source.ts.
  • libs/active-app/ directorio entero. Su contenido se consolidó en arts/active-app/{consts,errors,events}.ts.
  • arts/cache: bus y autoInvalidateOn options, wireAutoInvalidation, CACHE_AUTO_INVALIDATE_* constantes y types.
  • arts/perm: bus y autoInvalidateOn, wireAutoInvalidation, PERM_AUTO_INVALIDATE_*.
  • arts/connection: bus option en EngineConnectionsOptions, shouldWireBusSessionSource helper.
  • arts/active-app/active-app.svelte.ts:
    • createSiumEngine(), createActiveSession(), createActiveConnections(), createActivePerms(), createActiveAuth() factory methods.
    • App.Sess/App.Perms/App.Auth getters y singleton guards.
    • Sistema APP_ORCHESTRATION_* entero (presets, translators, resolveActiveAppOrchestration, STANDARD_ORCHESTRATION_TRANSLATORS).
    • APP_ERROR_ALREADY_CREATED_* y APP_ERROR_CREATE_PERM_ENDPOINT_REQUIRED constantes.
  • APP_EVENT_USER_IDENTITY_CHANGED, TENANT_SWITCHED, PERMISSIONS_REFRESH_REQUESTED, CACHE_INVALIDATE_REQUESTED, CONNECTIVITY_CHANGED. Solo sobrevive APP_EVENT_DISPOSE_STARTING.
  • Sus payloads y APP_USER_IDENTITY_CAUSE_*.
  • publishApp{UserIdentityChanged, PermsRefreshRequested, CacheInvalidateRequested, TenantSwitched, ConnectivityChanged}. Sobreviven publishAppDisposeStarting y el nuevo onAppDisposeStarting.

Forma final de arts/active-app/

arts/active-app/
├── README.md
├── refactorizacion.md          (este documento)
├── active-app.svelte.ts        — createActiveApp() compositor + schema
├── bus-context.svelte.ts       — getBus / setBus para Svelte component context
├── consts.ts                   — APP_MODULE, APP_BUS_CONTEXT_KEY, runtime gates
├── errors.ts                   — todo el infra de errores (ya no en libs/)
├── events.ts                   — APP_EVENT_DISPOSE_STARTING + helpers
├── types.ts                    — ActiveApp<S, TSchema>, ActiveAppOptions, etc.
├── services.ts                 — AppServiceFactory, CoreServices, schema types
├── service-builder.ts          — topología, lazy proxies, dispose
├── service-factories/          — define*() para cada art (12 archivos)
├── presets/                    — orca actions opt-in + applyStandardOrca
├── integrations/               — frontend-storage (lo único que queda)
├── testing/                    — createTestApp helper
└── test/                       — composition + schema + presets + builder + factories

libs/active-app/ ya no existe.

Suite de tests

1374 tests pasan. La diferencia respecto al pico de 1408 son los tests legacy eliminados; la cobertura del modelo nuevo es comprehensiva (schema-declarative.test.ts, service-builder.test.ts, service-factories.test.ts, presets.test.ts, active-app.test.ts core, más las suites por art).

Powered by TurnKey Linux.