Repository navigation
v3.21.0
release: v3.21.0 - named, module-owned principals for sync conditions
Slothlet v3.21.0 Changelog
Release Date: September 2026
Release Type: Minor
Branch: release/3.21.0
Overview
Version 3.21.0 adds principals to the permission system. A principal is a named, module-owned resolver that turns a caller identity into authorization facts, such as a user's per-project roles or an org's plan. Slothlet resolves it asynchronously, caches it per identity, and passes it read-only to the synchronous conditions of the rules that declare they need it (requires: ["roles"] → condition(ctx, { args, target, principals })). Per-resource gates that depend on facts only available asynchronously no longer need hand-rolled caching or context injection at every call site.
The release also makes api.slothlet.metadata.caller() / .self() reachable under defaultPolicy: "deny" without a user rule, and lets an instance start with no base directory and be built entirely through api.slothlet.api.add(). It fixes three bugs:
- a lazy
api.add()could resolve before its mount had finished materializing; - a module exporting
name/length/… was shadowed by the wrapper; - the TypeScript temp-dir fallback left an empty directory behind in the system temp directory.
Everything is backward-compatible. The one visible behavior change is that a synchronous condition returning a thenable is now a non-match instead of failing open.
✨ Features
Principals — async authorization facts for sync conditions (#459)
A rule condition decides a single call synchronously, but real per-resource gates usually need facts about the caller that are only available asynchronously. A principal supplies them:
// Inside the roles module (e.g. its initialize routine)
self.slothlet.permissions.principal.register("roles", {
key: (ctx) => ctx.user?.id, // identity key for this principal (sync)
resolve: async (userId) => loadRoles(userId), // facts for that identity (may be async)
maxAge: 30_000 // optional
});
// A rule declares the principals it needs; the condition receives them
{
caller: "client.**",
target: "project.files.list",
effect: "allow",
requires: ["roles"],
condition: (ctx, { args, principals }) => principals.roles.projects[args[0]]?.includes("read") === true
}- Management surface:
api.slothlet.permissions.principal.register/unregister/invalidate. It is host-only by default, through a built-in deny onslothlet.permissions.principal.**; the host grants it to the modules that should define principals. The first registrant owns a name, so another module can't replace its resolver (PRINCIPAL_NAME_OWNED/PRINCIPAL_NOT_OWNER). A module's resolver runs as that module. - Fail closed: a rule whose required principal is unregistered, dormant, has no identity for the call, or isn't current (and the call can't wait) doesn't match. The call falls through to other rules and
defaultPolicy.DEBUG_PERMISSION_PRINCIPAL_UNAVAILABLErecords which principal was unavailable and why. - Lazy, per-principal resolution with a sync fast path: calls whose principals are current stay synchronous. When a principal is stale, only that call waits, and only for the stale principal. It is enforced against gate inputs captured before the resolve, then runs. Sites that can't wait (construct, read gating, hooks, events) treat a stale principal as a non-match.
- Caching: results are cached per identity, with an epoch and an optional
maxAge.invalidateworks per identity or for a whole principal, including afterseal(). A resolve that an invalidation overtakes is discarded. - Read-only values: conditions see recursive read-only views (
PRINCIPAL_READ_ONLYon write). - Lifecycle:
- Removing the owning module removes its principals.
- Reloading the owner makes them dormant (name kept, no resolver) until it registers them again.
- A full reload replays host registrations and keeps module-owned names reserved.
seal()freezes register/unregister.
- Safety fix: a synchronous condition that returns a thenable is now a non-match. Previously an allow rule with such a condition failed open.
Implemented in #469. See the new Principals section in docs/PERMISSIONS.md.
slothlet.metadata.caller / metadata.self allowed by default (#468)
Under defaultPolicy: "deny", the only built-in allows on the slothlet.* surface were slothlet.lockCaller and slothlet.bind. A module calling self.slothlet.metadata.caller() or .self(), the documented way to authorize or scope by caller, was denied unless every host re-added the same rule. Both now have built-in allow rules. They reveal identity only and grant no data or control access. slothlet.metadata.get(path) stays gated, and a user rule of equal or higher specificity still overrides the built-ins. Implemented in #473.
🐛 Bug Fixes
An instance no longer requires a base directory (#471)
api.slothlet.api.add() was always meant to build a tree from scratch, but base was required, so an instance built purely at runtime had to point at a placeholder directory. With base omitted, or base: null, an instance in node or browser mode now composes an empty root with no WARN_DIRECTORY_EMPTY, and is built through api.add(): directory, file and in-memory { exports } forms, at a path or at the root. Permissions, removal, full reload() and scoped api.reload() work as usual; reload replays what api.add() built.
A base that is given but unusable still fails loudly:
base: ""throwsINVALID_CONFIG_DIR_MISSING.- A path that doesn't exist still throws.
- An existing but empty directory still warns.
- In browser mode, a manifest listing entries without a base throws, because those entries can't be resolved without a base URL.
Browser mode requires a manifest only when a base is given. Fixed in #482.
Exports named name / length / … are reachable and read-gated (#475)
The wrapper answered name (from the api path), length (the impl's arity), toString, valueOf, toJSON and constructor itself, before looking at the module's members. A module exporting one of those names was therefore unreachable; every @cldmv/rummage extension exports name, and self.owner.name returned "owner". The read also bypassed permission enforcement.
When a module exports one of those names, the read now returns the export and is gated like any other member. Modules without such an export keep the wrapper's answers, function impls are unaffected, and then stays reserved. An unloaded lazy module has no members yet, so the wrapper keeps answering until the module loads; docs/MODULE-STRUCTURE.md documents reading such an export after load. Fixed in #478.
Lazy api.add() resolves with its mount fully built (#462)
Ownership registration walked a freshly added subtree through the wrappers' proxies. The proxies' get trap starts a fire-and-forget materialization on every lazy child it hands out, so a lazy-mode api.add() could resolve while the mount's top-level wrappers were still materializing. What it returned depended on timing. Registration now bypasses the proxy only for unmaterialized lazy children, which register their own descendants when they materialize. Ownership records match eager mode exactly, and lazy add() no longer loads the mount's nested folders early. Fixed in #470.
TypeScript temp-dir fallback no longer leaks directories (#465)
When a .ts/.mts file has no package root above it, the transform cache lives under a private mkdtemp root in the system temp directory. shutdown() removed the instance's cache directory inside it but never the root, so every process that hit the fallback left an empty slothlet-XXXXXX directory behind. The root is now PID-named (slothlet-<pid>-XXXXXX) and removed once no instance in the process still uses it. A process's first fallback use also sweeps this user's roots whose PID is dead. Symlinks and other users' entries are never touched. Fixed in #472.
🔧 CI & tooling
- API structure debug checks run in CI —
npm testnow runsnpm run debug, the eager/lazy composition parity checks, after the vitest and node-native suites. They had run only in the local precommit, which is why #462 went unnoticed from v3.16.0 to v3.20.0 (#470). - v4 workflows synced with the
CLDMV/.githubv4.29.0 templates, keeping slothlet's repo-specific settings. Added the missingdependabot-recreate,member-auto-merge,pr-notifyandprovenanceworkflows, and grouped Dependabot PRs (#480). - Test scratch kept out of the system temp directory — all test scratch now lives under
tmp/test-fixtures/and is swept once its owning process exits. A chokidar fixture that watched the whole system temp directory, stalling full runs for 20+ minutes, now watches its own folder (#464, #466). - Setup-hook timeouts raised to 60s in eight load-sensitive suites whose
beforeEachexceeded the default 10s on a heavily loaded machine. Assertions are unchanged (#474, #476). - File headers added or repaired across 57 files with
npm run fix:headers. Headers only (#463). - LICENSE restored to the verbatim Apache-2.0 text; section 6 was missing a phrase (#479).
📚 Documentation
- NEW: docs/changelog/v3/v3.21.0.md — this changelog.
- docs/PERMISSIONS.md — new Principals section with a worked example, the
principal.*management surface, and theslothlet.metadata.caller/metadata.selfbuilt-in allows (#459, #468). - docs/PERMISSIONS-CONDITIONS.md —
callMeta.principalsandrequires(#459). - docs/CONTEXT-PROPAGATION.md — cross-reference to principals (#459).
- docs/METADATA.md — cross-reference to the metadata built-in allows (#468).
- docs/CONFIGURATION.md and docs/BROWSER.md —
baseis optional; how to start with no base directory (#471). - docs/MODULE-STRUCTURE.md — exports named
name/length/ … and lazy modules (#475).
🔧 Dependencies
No dependency updates.
Upgrade notes
This is a drop-in upgrade for v3.20.x.
- Principals are opt-in: rules without
requiresand existing conditions behave as before. - If a synchronous condition returned a Promise and relied on an allow rule matching, it no longer matches. It previously failed open, so declare a principal for the async part instead.
- A module that exports
name,length,toString,valueOf,toJSONorconstructornow gets its export (read-gated) instead of the wrapper's answer. Code that readapi.x.nameexpecting the api path of such a module should read the path from metadata instead. - Configs that previously passed a placeholder
baseonly to build throughapi.add()can drop it.
| Metric | Coverage |
|---|---|
| Statements | 99.9% |
| Branches | 99.8% |
| Functions | 100.0% |
| Lines | 99.9% |
Avg: 99.9% · 2c158f4 · Node lts/*