import { z } from "zod"; import { moduleIdSchema } from "./identifiers.js"; /** * The two axes of a module's operational state, kept deliberately separate because they answer * different questions, change at different times, and have different owners. * * **Configuration** is what an administrator decided: is this module supposed to run? It is * persistent, survives restarts, and is platform-owned — a module never declares, reads, or * influences it (ADR 0028). **Runtime** is what this process actually did with the module during * *this* boot. Because Ekklesia has no hot loading or unloading, the two can legitimately * disagree between a configuration change and the next restart, and the read model reports both * rather than pretending a change took effect immediately. * * This replaces the nine-name `MODULE_LIFECYCLE_STAGES` vocabulary that shipped with ADR 0006 and * which nothing ever implemented — ADR 0025 and ADR 0026 both recorded it as "vocabulary with no * runtime state machine behind it". Every name below is produced by real code and observable * through `GET /api/v1/module-lifecycle`; a stage that could never be observed would be a * contract that lies. */ export const MODULE_RUNTIME_STATES = ["running", "disabled", "blocked"] as const; export const moduleRuntimeStateSchema = z.enum(MODULE_RUNTIME_STATES); /** * What this process did with one module. * * - `running` — the module was enabled and eligible, and the module runtime loaded, registered, * and started it. It says the module's `start` hook completed; it says **nothing** about whether * every external resource that module uses is reachable now. Ekklesia has no runtime * health-probe contract, and this state must never be read as one. * - `disabled` — configuration says it must not run, so the platform never imported its server * entrypoint, never invoked a lifecycle hook, never registered a route, and never published its * UI. * - `blocked` — configuration says it should run, but a *required* dependency cannot, so starting * it would violate the dependency contract. See `ModuleBlockedReason`. * * There is deliberately no `failed`. Module startup remains all-or-nothing (ADR 0015, ADR 0020, * preserved by ADR 0028): a module that fails to load, register, or start aborts API startup * entirely, so no live process can ever report it. Adding the name before the behavior exists would * publish a state nothing can produce. */ export type ModuleRuntimeState = z.infer; /** * Why an enabled module is not running. A structured reason plus the exact thing it names — a * dependency, or a configuration key — rather than one state name per case, which would encode the * same two facts twice. * * `required_dependency_disabled` means the dependency an administrator turned off is the direct * cause. `required_dependency_blocked` means the named dependency is itself blocked, so the cause * is transitive; following the chain always terminates at a reason that is not * `required_dependency_blocked`, because a dependency that is merely absent or incompatible is * rejected by discovery long before eligibility is resolved. Since ADR 0029 that terminal reason * may also be one of the three configuration reasons below — a module blocked by its own * configuration blocks its required dependents exactly like a disabled one does. */ export const MODULE_BLOCKED_REASONS = [ "required_dependency_disabled", "required_dependency_blocked", "configuration_missing", "configuration_invalid", "secret_missing", "storage_unavailable", ] as const; export const moduleBlockedReasonSchema = z.enum(MODULE_BLOCKED_REASONS); export type ModuleBlockedReason = z.infer; /** * The three configuration reasons an enabled module may fail to become eligible (ADR 0029). They * are separate because each has a **different operational fix**, which is the only justification * for a distinct reason code: * * - `configuration_missing` — a required key has no stored value and no default. Fix: set a value * through the configuration API. * - `configuration_invalid` — a stored value exists but does not satisfy the module's own declared * type or constraints. Fix: correct the value. A persisted value is never silently coerced, * truncated, or replaced by a default. * - `secret_missing` — a required `secret` key has no deployment-supplied value. Fix: set it in the * deployment environment, not in the database — Ekklesia never stores secret material * (ADR 0029). * * Each names the offending **key**, never the value. A key is public — it is declared in an * installed module's manifest — while a value may be a credential. */ export const MODULE_CONFIGURATION_BLOCKED_REASONS = [ "configuration_missing", "configuration_invalid", "secret_missing", ] as const; export type ModuleConfigurationBlockedReason = (typeof MODULE_CONFIGURATION_BLOCKED_REASONS)[number]; /** * The module declares a logical object-storage bucket, and this host has no storage provider * configured to bind it to (ADR 0030). Fix: configure an S3-compatible storage provider for the * deployment. * * It names the **logical bucket** the module declared, never an endpoint, a credential, or a * physical bucket name. * * Deliberately narrow: this is a *binding* problem, not a health problem. A configured provider that * is briefly unreachable is an operational condition a module handles and retries — it never changes * a module's lifecycle state, because a slideshow whose object store blinked is still a running * slideshow. */ export const MODULE_STORAGE_BLOCKED_REASON = "storage_unavailable" as const; export function isModuleConfigurationBlockedReason( reason: ModuleBlockedReason, ): reason is ModuleConfigurationBlockedReason { return (MODULE_CONFIGURATION_BLOCKED_REASONS as readonly string[]).includes(reason); } /** Both kinds of edge a module's manifest can declare as required: another module, or a capability * some other module provides. Both block identically — a required capability is exactly as * required as a required module — and both name the *providing module* so an operator has * something they can act on. */ export const MODULE_DEPENDENCY_KINDS = ["module", "capability"] as const; export const moduleDependencyKindSchema = z.enum(MODULE_DEPENDENCY_KINDS); export type ModuleDependencyKind = z.infer; /** * The dependency that caused a block: which module is unavailable, and — for a capability edge * — which capability was being depended on. `moduleId` is always the module an administrator * would have to enable; `capabilityId` is present only when the edge came from * `dependencies.capabilities.required`. */ export interface ModuleBlockingDependency { readonly kind: ModuleDependencyKind; readonly moduleId: string; readonly capabilityId?: string; } /** One persisted enablement decision. Deliberately the smallest possible record: an identity and a * boolean. There is no install source, download URL, update channel, signature, marketplace * metadata, or free-form JSON — none of those exist in this platform, and a column for one would * invite inventing it (ADR 0028). */ export const moduleEnablementRecordSchema = z .object({ moduleId: moduleIdSchema, enabled: z.boolean(), }) .strict(); export type ModuleEnablementRecord = z.infer;