Repository navigation
Stackable lifecycle routines (initialize / shutdown; extensible, disable-able, per-item run mode) #341
Description
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
initializejust 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 defaultsslothlet.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) →modeoptional, 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.addcomposed them); a laterreload/addre-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
SlothletErrorreporting the failingmoduleID/routine, per the existingonFailureconventions. - Reserved names: routine names join the reserved-export set (
MODULE-STRUCTURE.md) with this stack-merge rule instead of theMODULE_RESERVED_EXPORTrefusal; 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 callsself.<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:
- Frozen. Every value under
slothlet.defaults(and nested arrays/sets) isObject.freezed — a consumer spreadingslothlet.defaults.routinesgets a new array, but the exported original is immutable and can't be mutated out from under other consumers or slothlet itself. - 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
- A module exporting
initializeinto a shared namespace runs at compose end, fullself.*in extent, on each slothlet instance a host composes. - Two modules merging into one slug both run — deterministic mount order.
shutdownruns on dispose.- Item forms
"name","name:mode",{name},{name, mode}normalize correctly; omitted mode →manual. - Providing
routinesreplaces the built-ins;slothlet.defaults.routinesis exported to spread/filter;[]disables all; omitting the option keeps the defaults. - A routine function throwing is a named, attributable error, not a silent skip.
slothlet.defaultsnamespace exists, every value frozen, every value derived from the same internal constant/variable the runtime code reads (never a duplicated literal) — carries at leastroutinesandreservedExports;collisionMode/manifestFileat maintainer discretion;hookSubset/maxDepthexcluded pending their own single-source-of-truth cleanup;mutationsexcluded (not consumer-facing).
Foundations already present
- Reserved framework export-name handling (
docs/MODULE-STRUCTURE.md). moduleIDownership 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
Shinrai commentedon Sep 7, 2026
Companion / driving consumer: CLDMV/rummage#61 (the auth initialize-lifecycle inversion this unblocks).
[-]Stackable lifecycle-hook groups (initialize / shutdown, extensible, per-hook run mode)[/-][+]Stackable lifecycle run-groups (initialize / shutdown, extensible, per-item run mode)[/+][-]Stackable lifecycle run-groups (initialize / shutdown, extensible, per-item run mode)[/-][+]Stackable lifecycle routines (initialize / shutdown; extensible, disable-able, per-item run mode)[/+]Shinrai commentedon Sep 7, 2026
The
hookSubset/maxDepthexclusion from theslothlet.defaultssweep (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/apiDepthbecome eligible forslothlet.defaultsat the maintainer's discretion.Shinrai commentedon Sep 8, 2026
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
collectLifecycleHookstest suite: makinginitialize/shutdowndefault, 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
dirscan, or the target of an explicitapi.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."initialize"recursive: false, default)"admin.initialize"<mount>.admin.initializeexactlyrecursive: true"admin.initialize"^prefix)"^ext.*.initialize"compilePattern()inhelpers/pattern-matcher.mjs— the same*/**/{}/!syntax hooks and permissions already use)recursiveis a new third field on a routine config entry ({ name, mode?, recursive? }), defaultfalse. 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 separateapi.slothlet.api.add()call (temporal and unaffected by eager/lazy, sinceadd()always registers synchronously as it mounts), and discovery/enumeration order for siblings that were never individuallyadd()-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 secondapi.add()atauththat doesn't itself exportinitialize) 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/shutdownmodes,slothlet.defaults.routines, theROUTINE_FAILEDerror shape, andslothlet.defaultsas a frozen/derived namespace — stands as written.Shinrai commentedon Sep 8, 2026
Design amendment #2 —
destroymode, per-routineorder, best-effort error aggregation, and full replacement ofcollectLifecycleHooks's internals.Follow-up to the previous amendment. Comparing the routines system against the legacy
collectLifecycleHooksmechanism 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:
destroymodeis now"manual" | "startup" | "shutdown" | "destroy". Adestroy-mode routine's cascade runs fromapi.destroy();shutdown-mode routines still also run as part ofdestroy()(sincedestroy()calls the rootshutdown()internally), so this only matters for a routine meant to fire ondestroy()specifically.Per-routine
orderA 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"forshutdown/destroy(matching the legacy mechanism's hardcoded deepest-first teardown),"mount"forstartup/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 existingonFailure: "best-effort"convention and the legacy mechanism's own best-effort teardown. Each failure is still individually attributed; if any occurred, one aggregateROUTINE_FAILEDerror is thrown once everything has run, withcontext.failurescarrying every{apiPath, moduleID}pair andcontext.countthe total.collectLifecycleHooksnow fully replaces the legacy mechanism, not aliases itPreviously
collectLifecycleHooks: truetoggledautoRoutineswhile the old_collectLifecycleHookswalk kept running in parallel (de-duplicated via a__slothletRoutineStackbrand 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-namedshutdown/destroyleaves, and any existingshutdown/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._collectLifecycleHooksitself (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.docs: rewrite the #341 changelog notes as one cohesive feat description
docs: file the v3.16.0 changelog for stackable lifecycle routines (#341)
feat(lifecycle): add stackable lifecycle routines, replace collectLif…
feat(lifecycle): add stackRoutines, decoupled entirely from collision…
feat(lifecycle): add stackRoutines, decoupled entirely from collision…
cldmv-bot commentedon Sep 14, 2026
Closed in v3.16.0 — resolved by #354.