Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions api_tests/api_test_restart/conn.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/conn.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): the module's top level creates the emitter, so every fresh
* import yields a new one stamped with the import generation.
* @module api_test_restart.conn
*/
import { EventEmitter } from "node:events";

const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] });

const conn = new EventEmitter();
conn.generation = ++state.imports;

export default conn;
36 changes: 36 additions & 0 deletions api_tests/api_test_restart/counter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/counter.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): module-scope state that only a fresh import resets.
* @module api_test_restart.counter
*/

let count = 0;

/**
* Increment the module-scope counter.
* @returns {number} The new count.
*/
export function increment() {
count += 1;
return count;
}

/**
* Read the module-scope counter.
* @returns {number} The current count.
*/
export function current() {
return count;
}
28 changes: 28 additions & 0 deletions api_tests/api_test_restart/factory/widget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/factory/widget.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): a constructible default export stamped with the emitter
* generation of the import that defined it.
* @module api_test_restart.factory.widget
*/

const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] });

/**
* A constructor function whose instances record the conn generation current at construction.
* @returns {void}
*/
export default function Widget() {
this.generation = state.imports;
}
26 changes: 26 additions & 0 deletions api_tests/api_test_restart/initialize.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/initialize.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): the user's own root `initialize` (default startup routine).
* @module api_test_restart.initialize
*/

/**
* Record that the user's root initialize ran.
* @returns {void}
*/
export default function initialize() {
const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] });
(state.userInitializes ??= []).push(state.imports);
}
70 changes: 70 additions & 0 deletions api_tests/api_test_restart/probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/probe.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): reads through `self` from inside a leaf, so a test can check
* which instance `self` resolves to, and exposes a slow call for in-flight behavior.
* @module api_test_restart.probe
*/
import { self } from "@cldmv/slothlet/runtime";

/**
* The instance ID `self` resolves to.
* @returns {string} `self.slothlet.instanceID`.
*/
export function instanceID() {
return self.slothlet.instanceID;
}

/**
* The emitter generation `self.conn` resolves to.
* @returns {number} `self.conn.generation`.
*/
export function connGeneration() {
return self.conn.generation;
}

/**
* Wait, then report the emitter generation captured when the call started.
* @param {number} ms - Delay in milliseconds.
* @returns {Promise<number>} The generation seen at call start.
*/
export async function slow(ms) {
const generation = self.conn.generation;
await new Promise((resolve) => setTimeout(resolve, ms));
return generation;
}

/**
* Call the `blocked` leaf as this module (permission tests).
* @returns {string} The leaf's result.
*/
export function callBlocked() {
return self.secret.blocked();
}

/**
* Call the `open` leaf as this module (permission tests).
* @returns {string} The leaf's result.
*/
export function callOpen() {
return self.secret.open();
}

/**
* Restart the instance from inside this module (permission tests: host-only by default).
* @returns {Promise<unknown>} The restart result.
*/
export async function restartInstance() {
return self.slothlet.restart();
}
33 changes: 33 additions & 0 deletions api_tests/api_test_restart/secret.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/secret.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): permission targets.
* @module api_test_restart.secret
*/

/**
* Target of a config-declared deny rule.
* @returns {string} "blocked".
*/
export function blocked() {
return "blocked";
}

/**
* Target of a runtime-added deny rule.
* @returns {string} "open".
*/
export function open() {
return "open";
}
27 changes: 27 additions & 0 deletions api_tests/api_test_restart/shutdown.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/shutdown.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): the user's own root `shutdown`, which a restart's teardown
* must run β€” also when the restart was triggered by a module.
* @module api_test_restart.shutdown
*/

/**
* Record that the user's root shutdown ran.
* @returns {void}
*/
export default function shutdown() {
const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] });
(state.userShutdowns ??= []).push(state.imports);
}
30 changes: 30 additions & 0 deletions api_tests/api_test_restart/tracker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart/tracker.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): a shutdown routine contributor that records which import
* generation was torn down.
* @module api_test_restart.tracker
*/

const state = (globalThis.__slothletRestartFixture ??= { imports: 0, shutdowns: [] });
const generation = (state.trackerImports = (state.trackerImports ?? 0) + 1);

/**
* Shutdown routine: record this module's generation.
* @returns {void}
*/
export function shutdown() {
state.shutdowns.push(generation);
if (state.throwOnShutdown) throw new Error("tracker shutdown failure");
}
24 changes: 24 additions & 0 deletions api_tests/api_test_restart_extra/plugin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_restart_extra/plugin.mjs
* @Date: 2026-09-21T01:27:16+00:00 (1789954036)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-09-26 22:30:51 -07:00 (1790487051)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview restart() fixture (#504): mounted at runtime with api.slothlet.api.add().
* @module api_test_restart_extra.plugin
*/

/**
* @returns {string} "plugin".
*/
export function ping() {
return "plugin";
}
47 changes: 47 additions & 0 deletions docs/LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,53 @@ Emitted when all lazy-mode modules have been materialized. Requires `tracking: {

---

## Instance Events

Four events mark an instance coming up, going down, and being replaced by [`api.slothlet.restart()`](RELOAD.md#apislothletrestart). A restart emits all four, in this order:

| Order | Event | Emitted on | Fires when |
| ----- | ----------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| 1 | `restart` | old instance | `restart()` begins β€” before anything is torn down |
| 2 | `shutdown` | old instance | internal teardown begins (after `mode: "shutdown"` routines and the root `shutdown` hook have run), while the api is still up |
| 3 | `init` | new instance | the new instance has finished building, including `mode: "startup"` routines |
| 4 | `restarted` | new instance | the new instance is live behind the same `api` reference and held references have been re-pointed at it |

`shutdown` and `init` are not restart-specific: `shutdown` fires on every teardown (`api.shutdown()`, `api.slothlet.shutdown()`, `api.destroy()`), and `init` fires when `slothlet()` finishes the cold start. A `reload()` is the same instance re-reading its modules, so it emits neither.

**Event data:**

```javascript
// restart
{ instanceID: "slothlet_…" } // the instance being replaced
// shutdown
{ instanceID: "slothlet_…", restart: true } // restart: false for a plain shutdown
// init
{ instanceID: "slothlet_…", restart: true } // restart: false for the cold start
// restarted
{ instanceID: "slothlet_…", previousInstanceID: "slothlet_…" }
```

**Who receives them.** Each event reaches the subscribers of the instance it is emitted on. A restart builds the new instance from the original config and carries nothing runtime over, so:

- Handlers declared in the [`lifecycle` config option](#construction-time-subscription-lifecycle-config-option) receive **all four** β€” they are subscribed on the old instance and subscribed again on the new one, because the new one is built from the same config. The same handlers receive `init` at the cold start too.
- Handlers added at runtime with `api.slothlet.lifecycle.on(...)` belong to the old instance: they receive `restart` and `shutdown`, then are dropped with it. They never see `init` or `restarted`, nor any later event.

To act once a restart has completed, either declare a `restarted` (or `init`) handler in the `lifecycle` config option, or simply await the call β€” `await api.slothlet.restart()` resolves after `restarted` has been emitted.

```javascript
const api = await slothlet({
base: "./api",
lifecycle: {
restart: ({ instanceID }) => log(`restarting ${instanceID}`),
restarted: ({ instanceID, previousInstanceID }) => log(`${previousInstanceID} β†’ ${instanceID}`)
}
});

await api.slothlet.restart();
```

---

## Module Discovery Events

These fire from the module discovery + mount pipeline at `api.slothlet.api.modules.*` (see the dedicated module discovery docs for the full surface). They observe both the discovery phase and the per-module mount phase.
Expand Down
17 changes: 16 additions & 1 deletion docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,22 @@ To allow a trusted module to toggle permissions, add a more specific allow rule:
| `control.seal()` | One-way lock (v3.12.0+). Freezes the policy: after sealing, `enable`, `disable`, `addRule`, `removeRule`, `readGating`, and `principal.register` / `principal.unregister` throw `PERMISSION_SEALED`. Idempotent; there is no unseal. Enforcement keeps running, `principal.invalidate` keeps working, and `shutdown()` is never blocked. |
| `control.sealed` | Accessor β€” whether the control surface has been sealed (`boolean`). |

**Sealing the policy.** `control.seal()` locks the permission policy so it cannot be mutated again for the life of the instance β€” useful once a host has finished wiring rules and wants to guarantee no later code (including a rule-managing leaf) can widen access. Only the host or an explicitly-allowed module can call it, since `control.**` is deny-by-default for modules. The seal is preserved across `reload()`. It never blocks `shutdown()`, so teardown always works, and it does not change enforcement β€” sealed or not, rules evaluate the same.
**Sealing the policy.** `control.seal()` locks the permission policy so it cannot be mutated again for the life of the instance β€” useful once a host has finished wiring rules and wants to guarantee no later code (including a rule-managing leaf) can widen access. Only the host or an explicitly-allowed module can call it, since `control.**` is deny-by-default for modules. The seal is preserved across `reload()`. [`restart()`](RELOAD.md#apislothletrestart) is different: it rebuilds the instance from its original config, so the new instance has only the config's rules and is **not sealed** β€” seal it again afterwards if needed. The seal never blocks `shutdown()`, so teardown always works, and it does not change enforcement β€” sealed or not, rules evaluate the same.

### `restart` β€” Host-Only by Default

[`api.slothlet.restart()`](RELOAD.md#apislothletrestart) rebuilds the instance from its original config and discards every runtime rule, event rule, principal and the seal. A module that could call it could undo the host's runtime policy, so a built-in rule denies it to every module:

```javascript
// Built-in rule registered for every instance:
{ caller: "**", target: "slothlet.restart", effect: "deny" }
```

Like every built-in rule, it is enforced only when the instance has a `permissions` config. With no `permissions` block the permission system is off entirely, so modules can call `restart()`; configure `permissions` (even just `{ defaultPolicy: "allow" }`) to make it host-only. The host is never gated. To let a trusted module restart the instance, add an instance rule on the same exact target β€” equal specificity, higher layer, so it outranks the built-in (this holds under both `defaultPolicy: "allow"` and `"deny"`):

```javascript
{ caller: "admin.**", target: "slothlet.restart", effect: "allow" }
```

### Lifecycle methods and default routines β€” Host-Only

Expand Down
Loading
Loading