Round 2 for #1626 per sage-0314: F1a setter refuses a non-plain prototype, F1b organization writer validates and logs JSON.parse(bytes), F2 loader refuses unreadable paths, README top-level-code limit, control run. Gate 591/0, 0 skipped; controls pass; mutants 47/47 killed (slot sage-0316). Candidate manifest sha256 466c835f7dce79d20c7ab74c197a52b70e7fdeb0efae0456222dc6d0a633f219, 14 paths on base 0bdd573ff; no commit in base..1376caab9 touches them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
11 KiB
Row 131 / #1626: the plug-in layer, kind registry and loader (round 2)
Builder: Dewey. Brief: docs/plans/2026-10-14_prd-1.2-build.md, step 6b.
Requirements: REQ-PLUG-1 (the plug-in layer), REQ-LIMIT-4 (the first two kinds,
usage-source and limit-check). The plugins write goes through step 5's write
path (REQ-ROLE-3). Base: see base.txt. Source is in a detached worktree; nothing
here is pushed, wired into a host process or written to Jason's organization file.
Governing rulings: lead decision 143 (in-repo modules only until Jason rules on Vikunja #286; no path from config) and sage-0301 (choices 1, 3, 4 and 5 as proposed; choice 2 changed).
Round 2 (sage-0314)
Round 1: Darkwing approved (28561, N1 and N2); Filbert asked for changes
(28560, F1 and F2). Sage ruled on both in sage-0314. Each item below is a change
on top of round 1 (agents/dewey/work/queue-131/r1/, committed 09447eb67).
| Item | Change | Test | Mutant |
|---|---|---|---|
| F1a | plugin-settings.mjs set() refuses with invalid-plugins unless plugins has prototype Object.prototype or null. A Date or a Map has no own keys and passed checkPlugins. |
refusal list gains new Date(0) and a Map, still checking unchanged bytes and an empty log; the success test writes a null-prototype object |
46 |
| F1b | organization.mjs write() (row 122's writer, extended for this one change by sage-0314): after building the bytes it runs validateBusinessDocument on JSON.parse(bytes), and changes() logs that parsed value. |
"the writer checks the bytes it writes": a Date in plugins sent straight to writer.write, past the setter, throws BusinessError; bytes unchanged, log empty |
47 |
| F2 (Darkwing N1) | loader.mjs: one helper, fsCall, wraps the lstat in component() and both realpath calls. ENOENT and ENOTDIR stay missing; any other error refuses as unreadable, so load() logs it and keeps the kind's old modules instead of rejecting. |
chmod 000 on the module directory (lstat of index.mjs); after the checked barrier, chmod 000 on the module directory (realpath of index.mjs) and on <root>/packages (realpath of packages/plugins). Skipped as root. |
43, 44, 45 |
| N2 (doc-only) | packages/plugins/README.md, limits: a module's top-level code runs at import, before the export and interface-version checks; a refused module has still run and Node's cache keeps it. The check list names unreadable. |
none (doc) | none |
| Control | mutants.py first runs each distinct test set on the unmutated source and stops if any fails. |
MUTANTS.md, control table |
Two choices, recorded:
- The in-memory validate stays.
write()still runsvalidateBusinessDocument(document)beforeJSON.stringify, then validates the parsed bytes. Without the first, aBigIntor a circular value would throw a rawTypeErrorfromJSON.stringifyinstead of a coded refusal. Sage's wording was "build bytes first, then validate the parse"; this does that and keeps the earlier check. changes(old, written)has no mutant of its own. For any document that passes both validations,writtenanddocumentgive the samechanges()result, so no test can tellchanges(old, document)apart. Mutant 47 covers the validation, which is what refuses.
The <root>/packages case loads usage-source only: a locked packages
directory would refuse limit-check too, so the test names one kind and checks
limit-check switched off by its absent key.
organization.mjs is now in the candidate (14 paths). Rows 121 and 123 don't
list it. packages/business/README.md gains one sentence on the writer
checking its bytes (row 123 also edits that file).
What ships
| Path | Change |
|---|---|
packages/plugins/package.json |
new package @mosaic/plugins |
packages/plugins/src/kinds.mjs |
kind registry: usage-source and limit-check, interface version 1, each switched on by a list; checkPlugins for the organization file's plugins key |
packages/plugins/src/loader.mjs |
createLoader({log}) → {load, loaded, modules} |
packages/plugins/README.md |
kinds, export contract, adding a module, the key, the loader and its limits |
packages/plugins/tests/{helpers,kinds.test,loader.test}.mjs |
21 tests |
packages/business/src/business.mjs |
plugins in TOP_KEYS; checked with checkPlugins; returned frozen ({} when absent) |
packages/business/src/plugin-settings.mjs |
the host setter: the only writer of plugins, through organizationWriter |
packages/business/tests/plugin-settings.test.mjs |
4 tests: schema, setter success and log, setter refusals, writer refuses what JSON can't carry |
packages/business/README.md |
the plugins key and its writer; the writer checks the bytes it writes |
packages/business/src/organization.mjs |
write() validates and logs JSON.parse(bytes) (F1b) |
scripts/stack-copy.sh |
whole packages/plugins in COPY_PATHS (approved, sage-0301) |
packages/cli/tests/stack-copy.test.mjs |
the copied-path list includes the plug-in sources |
No module of either kind ships. No core module imports a plug-in module by
name: business.mjs and plugin-settings.mjs import only kinds.mjs, and
nothing imports loader.mjs outside its tests.
No limit ceiling is in code (Q150).
sage-0301 rulings, as built
- One writer.
pluginSettings({dir, rolesDir}).set(id, {revision, plugins}, actor)is the only writer. No broker verb, mosaic command, Settings form or chief-of-staff write was added. - A refused write throws a code and logs nothing.
setchecks the request shape (invalid-request: anything butrevisionandplugins, or a revision that isn't 64 hex) and the value (invalid-plugins, fromcheckPlugins) before the writer runs. The writer validates the whole document before itspreparedentry, so nothing reacheschanges.jsonl. The test checks the file bytes are unchanged and the log is empty after thirteen refusals (aDateand aMapadded in round 2). A committed write logs actor, transaction and old and new names per kind, the same way a limit change does (pluginswith old null when the key was absent;plugins.<kind>with whole old and new lists after that). Load-time refusals go through the loader'slogcallback. - Last good modules per kind. A loader instance holds, per kind, the
modules from its last good load. Any refusal for a kind keeps them. A kind
refused on its first load has nothing on. The test loads
["meter","absent"]so a half-loaded list would show.load()andloaded()report what each kind actually has on. Every refusal callslogonce with{event: "plugin-refused", kind, name, code, message, kept}; the first-load test checks kind and name. The loader is not wired into any host process. - Export contract. Every module exports
interfaceVersion; usage-source exportsreadTurn; limit-check exportsmayLaunchandmustStop; both kinds switch on a list. The README carries the contract. These names bind row 124. Argument and return shapes are left to the calling rows. - What is checked.
packages/plugins,<kind>,<name>(each an lstat: exists, a directory, not a symlink) andindex.mjs(exists, a regular file, not a symlink); not the ancestors ofpackages/plugins. Just beforeimport,realpath(index.mjs)must equalrealpath(packages/plugins)/<kind>/<name>/index.mjs, and the import uses that expected path. An error other than ENOENT or ENOTDIR from those reads (EACCES, ELOOP) refuses asunreadable(round 2, F2).
The check-then-import gap
For Darkwing, in those words: there is a check-then-import gap. The lstat
checks and the realpath comparison run, then import() opens the file by path.
Someone who can write packages/plugins as the host's user can swap
index.mjs (or a directory above it) between the realpath check and the open
inside import(). Node has no import-from-descriptor, so the loader cannot
close the gap. The checks catch mistakes, stale links and a link planted
before load; they do not stop a writer racing the host as the same user, and
such a writer could edit any core file anyway. The test with the checked
barrier swaps index.mjs for a symlink after the lstat checks and shows the
realpath check refusing it; it cannot show the narrower window after realpath.
The README states the same limit. Two related limits: a module runs with the
host's authority once imported (the loader does not sandbox it), and Node
caches modules by URL, so a changed index.mjs is not re-read until restart.
The packages/plugins symlink case is only reachable when Node keeps symlinked
paths (--preserve-symlinks); otherwise import.meta.url is already the real
path. The test runs that case in a child node --preserve-symlinks.
Acceptance, mapped
| Acceptance item | Where |
|---|---|
| a test for each refusal, old setting kept | loader.test.mjs: bad name, missing (absent dir, empty dir, dir as index), symlink (name dir, index, kind dir, packages/plugins, realpath swap), load-failed (syntax error, top-level throw), missing export (absent, non-function, half of limit-check, no interfaceVersion), interface version (2, string "1"), unreadable (lstat, realpath of index.mjs, realpath of packages/plugins); each through refusesKeepingOld, which checks the kept setting, the one refusal entry and the one log call |
| fixture module of each kind switched on from a test organization file, no core file changed | loader.test.mjs first test: a business document built with business test helpers, validated by validateBusinessDocument, its plugins passed to the loader; fixture modules written into a temp copy of the package |
| one mutant per check, each failing a test | MUTANTS.md, mutants.py, mutants/results.json |
plugins key in the schema, written through step 5's write path and logged like a limit change |
plugin-settings.test.mjs |
REQ ids are in each test name.
Gate (decision 136, targeted)
Approved list (sage-0301): plugins, business, bus, webui, cli, each
node --test tests/*.test.mjs (the command land-work.sh runs), via gate.sh.
Round 2 run 2026-10-15T16:41:04Z to 16:42:25Z in the slot Sage gave in sage-0316,
on the candidate as shipped in build.patch. Logs: gate/, summary gate-run.log.
| Package | Tests | Pass | Fail | Skipped |
|---|---|---|---|---|
| plugins | 21 | 21 | 0 | 0 |
| business | 92 | 92 | 0 | 0 |
| bus | 171 | 171 | 0 | 0 |
| webui (includes pinned-copy.test.mjs) | 112 | 112 | 0 | 0 |
| cli (includes stack-copy.test.mjs) | 195 | 195 | 0 | 0 |
591 tests, 0 failed, 0 skipped: the chmod tests ran (not as root).
Not run: the other packages and the shell suites; Sage's full gate at the landing target covers them (decision 136). BASE..refactor HEAD touches none of the candidate's 14 paths (checked at packet time; see the commit message).
Mutants: control runs all pass; 47 of 47 killed (MUTANTS.md).
Overlap
Row 123 (Researcher) also edits packages/business/README.md. Sage orders the
landings; the second row rebases.