Skip to content

Stackable lifecycle routines (initialize / shutdown; extensible, disable-able, per-item run mode) #341

Closed

Description

@Shinrai

Design spec — 2026-09-06. Proposed slothlet feature. Driving consumer: CLDMV/rummage#61 (auth initialize-lifecycle).

Name: routines. These are not "hooks" (api.slothlet.hook.* is the call-interceptor system), not "phases" (slothlet's hooks already use before/primary/after phases, and module discovery has discovery/mount phases — reusing the word would be ambiguous), and not "lifecycle" (api.slothlet.lifecycle is the module-event emitter). routines is unused across the API surface. A routine is a named cross-module runnable that stacks its contributors and runs them together — a "startup routine", a "shutdown routine", etc. (Maintainer may still rename; the rest of the spec is name-agnostic.)

Problem

Multiple modules recursive-merge into one namespace by design (e.g. a coordinator and its contributors both mount at self.auth). There is no way today for "every module that mounted at self.auth" to run a setup/teardown function after the api is composed:

  • A leaf named initialize just recursive-merges to a single first-writer and drops every other contributor.
  • Enumerating self.<slug> from the host misses shared-slug contributors, and a host may compose several slothlet instances (a server host, a pre-boot gate, the app) — each needs its own pass.

Only slothlet sees every composed module (by moduleID) on every composition, so this belongs here.

The feature: a configurable list of stackable routines

Slothlet carries a configured list of routines. Each entry declares a name and an optional mode (manual | startup | shutdown; default manual when omitted).

Built-in defaults + how to disable/replace them

Slothlet ships defaults initialize → startup and shutdown → shutdown, reachable at slothlet.defaults.routines (see the slothlet.defaults namespace below). The config uses replace semantics — providing routines overwrites the built-ins (this is the off switch):

slothlet({ routines: [...slothlet.defaults.routines, "launch"] }) // extend the defaults
slothlet({ routines: ["launch"] })                                // REPLACE — no initialize/shutdown
slothlet({ routines: [] })                                        // disable all
slothlet({ /* omit routines */ })                                 // built-in defaults

slothlet.defaults.routines is exported so a consumer can spread it to extend, filter it to drop one (slothlet.defaults.routines.filter(r => r.name !== "shutdown")), or ignore it to replace wholesale. (A convenience disable token — e.g. a "!initialize" entry — could sit on top, but replace + the exported default is the primary mechanism.)

Entry forms (each array item)

  • "name" (string) → { name, mode: "manual" }.
  • "name:mode" (string, split on :) → { name, mode }.
  • { name, mode? } (object) → mode optional, defaults to "manual".
routines: [
  { name: "initialize", mode: "startup" },  // (a default; shown for reference)
  "launch",                                  // → { name: "launch",   mode: "manual" }
  { name: "warmup" },                        // → { name: "warmup",   mode: "manual" }
  "prefetch:startup",                        // → { name: "prefetch", mode: "startup" }
]

Stacked (chain) merge for routine names

For an ordinary leaf, recursive-merge keeps the first writer when two modules contribute the same path. For a configured routine name, slothlet instead composes contributors into a stack: at any composed path where ≥1 mounted module exports that name, self.<path>.<name> is one callable that invokes each contributor's function in mount order, sequentially, awaiting each. Contributors are keyed by moduleID internally (shared-slug contributors each keep their own); that never surfaces — the consumer sees one self.auth.initialize() that runs them all.

  • Order: mount order (the order api.add composed them); a later reload/add re-derives the chain.
  • Async: each contributor awaited before the next; the chain returns a promise.
  • Errors: one contributor throwing must not silently skip the rest — a named SlothletError reporting the failing moduleID/routine, per the existing onFailure conventions.
  • Reserved names: routine names join the reserved-export set (MODULE-STRUCTURE.md) with this stack-merge rule instead of the MODULE_RESERVED_EXPORT refusal; a module may export a routine and normal leaves side by side.

Root cascade per routine + run modes

For each configured routine, slothlet also generates a root cascade self.<name>() (≡ api.slothlet.<name>()) that walks the composed tree and runs every namespace's chain for that routine in a deterministic order (proposed: parent-before-child for startup, child-before-parent for shutdown — to confirm), awaiting each. Mode drives firing:

  • startup — run the cascade as the final awaited step of composition; await slothlet(...) resolves only once it completes. (A compose-time "everyone registered" barrier for free.)
  • shutdown — run at dispose/teardown (existing dispose path).
  • manual — never auto-run; host calls self.<name>() when it chooses.

slothlet.defaults namespace — frozen, single-sourced (no duplicated literals)

slothlet.defaults.routines implies a slothlet.defaults export namespace. Two hard requirements, to avoid a maintenance hazard:

  1. Frozen. Every value under slothlet.defaults (and nested arrays/sets) is Object.freezed — a consumer spreading slothlet.defaults.routines gets a new array, but the exported original is immutable and can't be mutated out from under other consumers or slothlet itself.
  2. Single source of truth. slothlet.defaults.<x> must derive from the exact internal variable/constant the runtime code itself reads — never a hand-copied literal placed next to it. If the internal default ever changes, the export changes with it, automatically; there must be no second place to remember to update. Where a default is currently a bare literal duplicated across call sites with no named constant, that is a prerequisite bug to fix first (introduce the single named constant, point every call site at it), not something to expose as-is — see the sweep below for which candidates need this.

Criterion for what belongs in the namespace at all: a default a consumer would reference or spread — not merely an internal fallback the framework applies when an option is omitted (see mutations, excluded below).

Sweep — verified against the actual source, not just the docs

candidate backing today frozen + single-sourced today? verdict
routines new list (this feature) will be, by construction expose
reservedExports UnifiedWrapper.INTERNAL_KEYS ∪ IMPL_METADATA_KEYS (unified-wrapper.mjs, read via isFrameworkReservedKey()) — two real Sets yes (two real Sets) — export must be their frozen union, derived, not copy-pasted expose (derive: Object.freeze(new Set([...INTERNAL_KEYS, ...IMPL_METADATA_KEYS])))
collisionMode DEFAULT_MODULE_COLLISION_MODE = "merge" (module-manager.mjs:53), read via ?? at its 2 call sites yes — one real named constant expose (optional; introspection value)
manifestFile DEFAULT_MANIFEST_FILE = "slothlet.module.json" (module-discovery.mjs:63) yes — one real named constant expose (optional; introspection value)
hookSubset "primary" — hand-typed literal, duplicated at hook-manager.mjs:267 (options.subset || "primary") AND in bare array literals ["before","primary","after"] at 4+ more call sites; no named constant anywhere no — no single source to derive from do NOT expose as-is — would require slothlet to first introduce a DEFAULT_HOOK_SUBSET/HOOK_SUBSETS constant and point every call site at it; out of scope for this feature
maxDepth Infinity — hand-typed literal, duplicated across eager.mjs:68, loader.mjs:447, config.mjs:532, lazy.mjs:93; no named constant no — same problem do NOT expose as-is — same prerequisite
mutations normalizeMutations()'s internal fallback { add, remove, reload, permissions } (config.mjs:203) n/a excluded — not consumer-facing; a consumer overrides individual keys and never references/spreads this object

So today's real, ready-to-expose content is routines + reservedExports, plus collisionMode/manifestFile as easy optional additions (both already single-constant-backed). hookSubset and maxDepth are flagged for a future pass once slothlet itself consolidates their scattered literals into named constants — do not fabricate those constants as part of this feature.

Module side

// any file composed into the tree:
export function initialize() { /* self.* is fully composed here */ }
export function shutdown()   { /* teardown */ }

Acceptance

  1. A module exporting initialize into a shared namespace runs at compose end, full self.* in extent, on each slothlet instance a host composes.
  2. Two modules merging into one slug both run — deterministic mount order.
  3. shutdown runs on dispose.
  4. Item forms "name", "name:mode", {name}, {name, mode} normalize correctly; omitted mode → manual.
  5. Providing routines replaces the built-ins; slothlet.defaults.routines is exported to spread/filter; [] disables all; omitting the option keeps the defaults.
  6. A routine function throwing is a named, attributable error, not a silent skip.
  7. slothlet.defaults namespace exists, every value frozen, every value derived from the same internal constant/variable the runtime code reads (never a duplicated literal) — carries at least routines and reservedExports; collisionMode/manifestFile at maintainer discretion; hookSubset/maxDepth excluded pending their own single-source-of-truth cleanup; mutations excluded (not consumer-facing).

Foundations already present

  • Reserved framework export-name handling (docs/MODULE-STRUCTURE.md).
  • moduleID ownership tracking (diag.caches.getAllModuleIDs(), per-path ownership — docs/CONFIGURATION.md).
  • Module-discovery + per-module mount lifecycle events (docs/LIFECYCLE.md).

Additive to those, not a rework.

Activity

  1. Shinrai commented on Sep 7, 2026

    @Shinrai
    ContributorAuthor

    Companion / driving consumer: CLDMV/rummage#61 (the auth initialize-lifecycle inversion this unblocks).

  2. changed the title [-]Stackable lifecycle-hook groups (initialize / shutdown, extensible, per-hook run mode)[/-] [+]Stackable lifecycle run-groups (initialize / shutdown, extensible, per-item run mode)[/+] on Sep 7, 2026
  3. changed the title [-]Stackable lifecycle run-groups (initialize / shutdown, extensible, per-item run mode)[/-] [+]Stackable lifecycle routines (initialize / shutdown; extensible, disable-able, per-item run mode)[/+] on Sep 7, 2026
  4. Shinrai commented on Sep 7, 2026

    @Shinrai
    ContributorAuthor

    The hookSubset/maxDepth exclusion from the slothlet.defaults sweep (in the issue body above) is now tracked as its own, independent cleanup: #343. Not a dependency either direction — this issue (routines) doesn't need #343, and #343 doesn't need routines. Once #343 lands, hookSubset/apiDepth become eligible for slothlet.defaults at the maintainer's discretion.

  5. Shinrai commented on Sep 8, 2026

    @Shinrai
    ContributorAuthor

    Design amendment — routine name matching, cascade ordering, and the lazy-mode materialization cost model.

    Implementation work surfaced a real conflict between the spec as written and the existing collectLifecycleHooks test suite: making initialize/shutdown default, always-on, match-anywhere-in-the-tree routine names collided with pre-existing nested leaves that happen to share those names for unrelated reasons, and a naive "walk the whole tree to discover every contributor" approach forced full materialization in lazy mode for even the common single-contributor case. The following supersedes the original "at any composed path where ≥1 mounted module exports that name" wording.

    Name matching is mount-relative by default

    A "mount point" is any location a module was loaded as its own discrete unit — a top-level entry from the initial dir scan, or the target of an explicit api.slothlet.api.add(mountPath, ...) call. Every routine name is resolved relative to each mount point, not as a bare suffix match against the full composed api path anywhere in the tree.

    Name form Example Resolves against Lazy materialization required
    Bare "initialize" Each mount's own top level only Never — a mount's own top level is always constructed eagerly, in both modes
    Mount-relative, dotted (recursive: false, default) "admin.initialize" <mount>.admin.initialize exactly That one mount's subtree, to resolve the fixed relative path
    Mount-relative, recursive: true "admin.initialize" Anywhere within each mount's own subtree, any depth That mount's entire subtree (depth is unbounded)
    Root-anchored (^ prefix) "^ext.*.initialize" The full absolute api path, crossing mount boundaries, compiled via the existing glob engine (compilePattern() in helpers/pattern-matcher.mjs — the same */**/{}/! syntax hooks and permissions already use) The entire composed tree (not mount-scoped)

    recursive is a new third field on a routine config entry ({ name, mode?, recursive? }), default false. It has no effect on a ^-anchored name — that form already expresses depth directly via * vs ** in the pattern.

    This makes the lazy-mode cost precise and pattern-dependent rather than a blanket cost: a bare name never forces materialization; a dotted or recursive mount-relative name costs only that one mount's subtree; a root-anchored name costs the whole tree. Documented as the one accepted crux of combining lazy mode with a non-bare routine name — never silent, always scoped to what the configured pattern actually requires.

    Root cascade ordering

    self.<name>() / api.slothlet.<name>() runs every registered contributor matching that name, everywhere it was found, as one flat list in registration order — not grouped or sorted by tree depth. "Registration order" means mount order for anything mounted via a separate api.slothlet.api.add() call (temporal and unaffected by eager/lazy, since add() always registers synchronously as it mounts), and discovery/enumeration order for siblings that were never individually add()-ed (loaded together in one compose pass — the only order that exists for those in either mode).

    Per-path stacking (unchanged in concept)

    Wherever multiple contributors resolve to the exact same full api path — a genuine mount collision, or two contributors coincidentally landing at the same resolved path under a wildcard — that path's own property is the merged callable, contributors invoked in registration order. This is additive to, not replaced by, the flat cascade above.

    Double-registration guard

    Contributors are deduplicated by (apiPath, moduleID). A later, unrelated mount touching the same parent path (e.g. a second api.add() at auth that doesn't itself export initialize) cannot cause an earlier contributor's registration to be counted twice, even if the merge incidentally re-touches the shared parent.

    Everything else in the original issue body — stacked (chain) merge semantics, the manual/startup/shutdown modes, slothlet.defaults.routines, the ROUTINE_FAILED error shape, and slothlet.defaults as a frozen/derived namespace — stands as written.

  6. Shinrai commented on Sep 8, 2026

    @Shinrai
    ContributorAuthor

    Design amendment #2 — destroy mode, per-routine order, best-effort error aggregation, and full replacement of collectLifecycleHooks's internals.

    Follow-up to the previous amendment. Comparing the routines system against the legacy collectLifecycleHooks mechanism it's meant to succeed surfaced three more gaps, now closed, plus a decision to fully replace rather than run the two mechanisms in parallel.

    Fourth mode: destroy

    mode is now "manual" | "startup" | "shutdown" | "destroy". A destroy-mode routine's cascade runs from api.destroy(); shutdown-mode routines still also run as part of destroy() (since destroy() calls the root shutdown() internally), so this only matters for a routine meant to fire on destroy() specifically.

    Per-routine order

    A routine entry gains a third field, order: "mount" | "depth" (object form only — the string shorthands always mean "mount"). "mount" preserves registration order (unchanged default behavior). "depth" runs the deepest-matching api path first — contributors colliding at the identical path still run in registration order relative to each other, since depth can't distinguish those. Default is mode-dependent: "depth" for shutdown/destroy (matching the legacy mechanism's hardcoded deepest-first teardown), "mount" for startup/manual.

    Error handling: best-effort, not fail-fast

    Reversing the original "stops the chain" design: every contributor now runs regardless of an earlier one's failure, mirroring module-manager.mjs's existing onFailure: "best-effort" convention and the legacy mechanism's own best-effort teardown. Each failure is still individually attributed; if any occurred, one aggregate ROUTINE_FAILED error is thrown once everything has run, with context.failures carrying every {apiPath, moduleID} pair and context.count the total.

    collectLifecycleHooks now fully replaces the legacy mechanism, not aliases it

    Previously collectLifecycleHooks: true toggled autoRoutines while the old _collectLifecycleHooks walk kept running in parallel (de-duplicated via a __slothletRoutineStack brand check). It now expands into two implicit routines — { name: "^**.shutdown", mode: "shutdown", order: "depth" } and { name: "^**.destroy", mode: "destroy", order: "depth" } — reproducing the legacy mechanism's whole-tree, cross-mount-boundary, deepest-first scope for literally-named shutdown/destroy leaves, and any existing shutdown/destroy-mode routine (including the built-in defaults) is dropped in favor of these root-anchored ones, since they strictly subsume a narrower mount-relative match. _collectLifecycleHooks itself (the standalone walking method) has been removed; the routine system's cascade is now the sole discovery and invocation mechanism for both the new feature and the legacy option alike.

  7. added
    area: coreTouches core library / runtime source code
    type: featureImplements new functionality — a PR or issue that adds a feature
    on Sep 13, 2026
  8. cldmv-bot commented on Sep 14, 2026

    @cldmv-bot
    Contributor

    Closed in v3.16.0 — resolved by #354.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreTouches core library / runtime source codestatus: verifiedBuilt and tested end-to-endtype: featureImplements new functionality — a PR or issue that adds a feature

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions