Repository navigation
feat(cli, runtime, core): os serve loads the plugins os plugin install installed, re-verified and dispatched by runtime tier; os plugin disable | enable | uninstall (#27123, slice 2 of 3) - #27374
Conversation
…ed and dispatched by runtime tier; os plugin disable | enable | uninstall (#27123) Slice 2 of 3 of ADR-0025 §3.5 M1 (Claim 6485671080). - runtime: installed-plugin-loader.ts (loadInstalledPlugins, verifyInstalledPlugins, dispatchPluginRuntime). Each installation is re-checked before any of its code is imported: its own install dir, disabled, the kept artifact's sha256, the signature over the raw kept bytes against sys_trusted_key (revoked since install refuses), the unpacked tree byte-equal to the artifact, the manifest id/version, then the tier: node imports in process, sandbox/worker/undeclared refused by name. Uninstall finishes at boot with a shared-dir guard. - runtime: the installations file takes a pid lock for every read-modify-write; a stale lock (dead pid, older than 5 min, own pid while unheld, unparseable and older than 1 s) is cleared by the next writer. - core: the archive reader moves here with a bounded inflate (MAX_UNPACKED_ARTIFACT_BYTES); PluginLoader refuses a signature no host verified (recordPluginArtifactVerdict). - cli: serve registers installed plugins before bootstrap; install verifies the signature before inflating and warns on a tier the boot refuses; new disable / enable / uninstall; the shared trust read plugin-trust-read.ts. - spec: the runtime tier's describe text and TSDoc, its pin, the liveness row live-elsewhere -> live; generated reference pages regenerated. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 185 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7df566a9ac04e4bf3954330802494a1c0d38b971 && git checkout 7df566a9ac04e4bf3954330802494a1c0d38b971
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57c51aab4d2f63293676e9412daed8a5fc54c1bc 820d2772a6cc5fa2cd9c5af4ef52202764bbe4f9 && git checkout -B drift-repro 57c51aab4d2f63293676e9412daed8a5fc54c1bc && git merge --no-ff 820d2772a6cc5fa2cd9c5af4ef52202764bbe4f9
node scripts/docs-audit/affected-docs.mjs --json 57c51aab4d2f63293676e9412daed8a5fc54c1bc
|
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract-review-claim: The lane seat takes the Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract reviewServed-tier: Inputs: card #27123 (body and all 13 comments: triage ① Derived judgments
② Semver level
③ Boundary flagsDev flags (PR body "Known limitations", report
④ Design judgment
Every sentence of the new text, checked in the tree:
Break attempts, each against the live tree:
Findings on the hits file:
Implemented-by: VERDICT: PASS Generated by Claude Code |
… loader (#27123) Seat review r1, contract review 6487771335, and the two CI reds on a6a7e49. - runtime: a stale installations lock is cleared only under <lock>.steal and only while it is still the file judged stale (inode + bytes); locks carry pid, thread and a fresh token, and are released only while still the holder's. The own-pid clause compares the thread. - runtime: a throw on one installation refuses it by name (install_unreadable, uninstall_failed, records_unreadable), and the boot goes on. Uninstall finishes under the lock against a re-read record (record_changed otherwise). - runtime: the installed-plugin path reads the disable-state file strictly (loadDisabledPackageIdsStrict): a corrupt file refuses each plugin disabled_state_unreadable instead of loading disabled ones, and disable / enable refuse disable_state_failed and write nothing. - runtime: a bare @objectstack/* import inside a verified install dir resolves to the host's packages (module.register resolve hook), so a plugin os plugin build produced loads. - core: readTar honours the typeflag (regular files only, directories skipped, others refused by name), joins a ustar prefix, refuses a cut-short entry and a bad size, and copies nothing. The verdict check is described as what it is, not an in-process boundary. - cli: loader_failed and kernel_refused at the serve wiring; the lock error matched by type; uninstall's stale "Marked" fixed; install size-checks the artifact before reading it. The serve e2e builds, signs, installs and boots a plugin from os plugin build. - spec: the runtime describes say "at boot for a plugin os plugin install installed"; the field TSDoc bridges the unset-tier sentences; the liveness README's account of the row; the liveness gate and lint self-tests plant the old live-elsewhere row instead of reading it from the shipped ledger. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract-review-claim: Round-1 rework head; the lane seat takes the Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract reviewServed-tier: Inputs: card #27123 (body and all 14 comments: triage Check-runs on ① Derived judgments
② Semver level
③ Boundary flagsThe four should-fixes of record
Dev flags on this head (PR body "Known limitations", Acceptance notes), each answered or escalated:
Carried from the card:
④ Design judgment
Every sentence of the text on this head, checked in the tree:
Break attempts, each against the live tree:
Findings on the hits file:
The narrowing, pre-judged on the narrowing face (ruling 6442046166's question), so the next record is short. Does Implemented-by: VERDICT: FAIL Generated by Claude Code |
… loader (#27123) Seat review r2 and contract review 6490914228 on af0b388. - runtime: a lock path that is there and cannot be read (a directory, EACCES) is refused by name at once instead of retried forever, which hung os serve at boot with an uninstall pending. A steal lock is judged by its pid like the lock, and a writer that finds one held sleeps between tries instead of spinning. - runtime: the host-package resolve hook matches install directories by their real path too, so an OS_HOME reached through a symbolic link resolves, and redirects only a specifier naming a package under @objectstack/ (no empty, . or .. segment, no % or backslash). - runtime, cli: a throw of any value (undefined, null) from a plugin is refused by name; it no longer rejects the whole loader. - core: the narrowing of kernel.use's signature check is declared: Clause-② no (narrowing), the BREAKING banner, and an ADR-0087 runtime-interface-only disposition; the signature field's TSDoc and content/docs/plugins/index.mdx no longer teach setting it by hand. - spec: the runtime field TSDoc names os plugin build as what makes an artifact carry code. - tests: the unreadable lock paths, the steal lock's judgement, the symlinked OS_HOME and the climbing specifier in a real Node process, throw undefined, the artifact size caps at install and boot, loader_failed and kernel_refused at the serve wiring, and uninstall's record-gone race; the serve e2e carries its measured test-budget. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract-review-claim: Round-2 head (declares Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract reviewServed-tier: Inputs: card #27123 (body and all 15 comments: triage Check-runs on ① Derived judgments
② Semver level
③ Boundary flagsThe FAIL record
Dev flags on this head (PR body "Known limitations", Acceptance notes, the report's three deviations), each answered or escalated:
Carried from the card:
④ Design judgment
Every sentence of the text on this head, checked in the tree:
Break attempts, each against the live tree: an on-disk manifest saying The narrowing (the narrowing face, ruling 6442046166's question): does The second narrowing (①.7, the shared reader in Implemented-by: VERDICT: PASS Generated by Claude Code |
) Seat review r3 and contract review on 766b96f. - runtime: the installations lock is read by lstat, so a path that is not a regular file (a directory, a symbolic link, dangling or not) is refused by name at once. A dangling link made the exclusive create fail and the followed read find nothing, and the writer spun forever. A lock gone when read now waits under the same deadline and pause as any other try, and refuses naming the lock. - runtime, cli: a thrown Error whose message is not a string (a Symbol) is converted, so the plugin is still refused by name. - docs: a --trust-unsigned install is checked by hash, not by key. - changeset: os plugin publish reads artifacts through core's reader too, so its preflight now refuses what install's does (an inflate past 256 MiB, a non-regular or cut-short entry); the reader sentence says so. - tests: the dangling link, the lock gone at every read, a live pid's steal lock past the stale bound, and a Symbol message at the loader and the serve wiring. Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract-review-claim: Round-3 head (findings-only commit over Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK Contract reviewServed-tier: Check-runs on ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
The narrowing (the narrowing face): The delta itself, judged as a change to the lock it belongs to: refusing a lock path the store could never have made ( Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK 席位核对 — ACCEPT ·
|
os plugin install and os plugin trust list (nightly red)
#27621
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK
Requested by Bill · project thread
Part of #27123
Ruling-ref: 6467932167
Clause-②: no (narrowing) —
@objectstack/core'sPluginLoader(so everykernel.use) refuses a plugin object whosesignatureno host recorded a verdict for, where any well-formed signature passed. No key or accepted value of@objectstack/specchanges: it changes only theruntimetier's.describe()text and TSDoc (the generated reference pages carrying it regenerated), its text pin, the livenessruntimerow, the liveness README's account of that row, and the liveness gate's self-testMetadata-protocol: none
Skills-docs: updated here —
content/docs/plugins/index.mdx, the one page that taught settingsignatureon a plugin object by hand, now showsrecordPluginArtifactVerdictbeforekernel.use; no skill underskills/**teaches the field维护者速读
os serve(以及经它的os dev/os start)启动时会加载os plugin install装好的插件。每个插件在导入任何代码之前都要重新复核:目录是否正确、所存制品的 sha256、签名及其公钥现在是否仍受信、解包后的文件是否与制品逐字节一致、清单的 id 和版本。复核通过后再按runtime分派:只有node在进程内加载,sandbox、worker和未声明的一律按名拒绝。插件里对@objectstack/*的裸导入解析到宿主自己的包。另外:新增os plugin disable | enable | uninstall;安装记录文件加了锁;install 改为先验签名再解压,且解压有上限;core 的PluginLoader签名检查不再只看格式,必须有宿主记录的验证结论。signature只检查格式,等于让插件自己给自己担保。✗行并在横幅上计数,不会中断启动。core 的签名检查对"带签名但没有宿主结论"的插件从放行改成拒绝,已声明为Clause-②: no (narrowing),带 BREAKING 横幅和 ADR-0087 处置,级别仍是 patch;仓库里没有任何插件设置signature,仓库外的宿主按 changeset 里的迁移说明改。回滚 = revert 本 PR。packages/runtime/src/installed-plugin-loader.ts(复核顺序、拒绝码、宿主包解析);packages/runtime/src/plugin-installation-store.ts(锁,以及陈旧锁怎么安全地清);packages/core/src/plugin-loader.ts的verifyPluginSignature;packages/cli/src/utils/installed-plugins-boot.ts(serve 的接入点)。pnpm --filter @objectstack/cli exec vitest run --project integration test/serve-installed-plugins.integration.test.ts,预期 7 个全绿。测试会启动真实的os serve --no-server子进程:已签名的 node 插件代码运行了;用os plugin build从源码构建、导入@objectstack/core的插件也加载了,且拿到的是宿主那份 core;sandbox 层和被篡改的插件按名拒绝,代码没跑;已禁用的跳过;已卸载的目录被删除。Review round 2: what changed
Head
af0b3888→766b96ff, one push. Each seat-review item (r2, CHANGES) and each contract-review item (FAIL6490914228).Seat review, must-fix
<installations>.lockthat was a directory (EEXIST on create, EISDIR on read) or unreadable (EACCES, for example one left by asudo os plugin installunder umask 077) made the writer loop with no sleep and no deadline.os servehung at boot while an uninstall was pending, andinstall/uninstallhung too.os plugincommand is running, remove it." At boot that isuninstall_failedfor the one plugin, and the next plugin still loads; the commands report it asinstall_failed/uninstall_failed.node:fsseam, since these tests may run as root) are each refused naming the lock in under 2 s, with the function never run and the path left as it was. At boot, an EACCES lock makes the uninstalluninstall_failednaming it, and the next plugin loads. Anti-vacuity: with the old reader restored, the directory case hangs until it is killed (timeout 90→ exit 143).Seat review, should-fix
The hook's roots are matched by real path. Node names an importing module by its real path, so an
OS_HOMEreached through a symbolic link (the macOS temp dir is one) never matched a root built withpathToFileURL(dir), and every built plugin failedimport_failed. The roots now hold bothdirandrealpathSync(dir).A specifier that climbs out of
@objectstack/is not redirected.@objectstack/../zod/package.jsonfrom the host is any file the host can resolve (measured: it resolves tonode_modules/zod/package.json). The hook now redirects only a specifier that names a package under@objectstack/: no empty,.or..segment, no%and no backslash. Anything else resolves as Node resolves it from the install directory.Tests for 2 and 3 boot the loader in a real Node child process (
node --import tsx), because the hook acts on Node's own loader, which the test runner's module graph does not go through. An unsigned plugin importing@objectstack/core, installed under anOS_HOMEthat is a symbolic link, loads and callssha256Hex. A plugin importing@objectstack/../zod/package.jsonis refusedimport_failed. Anti-vacuity: without the real path, the first test fails; with the oldstartsWith('@objectstack/')match, the second plugin loads.A throw of any value is named. A module whose top level does
throw undefined(or a getter that throwsnull) made(error as Error).messagethrow inside the catch, which rejected the whole loader asloader_failedand dropped every installed plugin. The loader and the serve wiring now read what was thrown through one guarded helper (error instanceof Error ? error.message : String(error), itself guarded), so the plugin is refused by name and the next one loads. Tests: twoit.eachrows (throw undefined→import_failed, a getter throwingnull→not_a_plugin), a boot where the next plugin still loads, and, at the serve wiring, a loader throwing anError,undefinedornull(oneloader_failedline each), a name getter throwingundefinedand akernel.usethrowingnull(kernel_refusedeach, the next plugin registered). Anti-vacuity: with the old message read, the two loader tests fail.Seat review, nits. All taken.
INSTALLATIONS_LOCK_STEAL_STALE_MSis gone). A live writer slow inside its clear keeps its steal lock however long it holds it; one a dead writer left is cleared at once. Tests: a fresh steal lock naming a dead pid is cleared; one naming a live pid and a minute old is kept.os plugincommand is running, remove X.lock and X.lock.steal." The live-steal test above counts the judgements in a 300 ms wait (fewer than 20; a spin would make thousands).MAX_UNPACKED_ARTIFACT_BYTESis refusedartifact_changedat boot with no read of it (counted through thenode:fsseam), and a sparse artifact file of that size is refusedartifact_unreadablebyos plugin install;loader_failedat the serve wiring (above), in a new unit test against a stand-in loader and kernel (installed-plugins-boot.test.ts, 6 tests, under a second);os plugin uninstall's record-gone race (uninstall.test.ts, 3 tests): a record found before the lock and gone under it isplugin_not_installed("removed while this command ran"), not "Marked"; a busy lock isinstallations_locked, any other failureuninstall_failed. The command runs on its own without loading the CLI's oclif config, which cost seconds per run for nothing the test reads.Contract review (FAIL
6490914228)Clause-②: no (narrowing)at the start of the line, the changeset body gains**BREAKING** accept-set narrowing at \kernel.use` (`@objectstack/core`)and one disposition,adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader)with its reason. The Migration paragraph stays; the level stayspatchwithMetadata-protocol: none`. Run locally on the round's commit:check-adr-0087-registration --base origin/main: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+clause-②-narrowing] not-required (runtime-interface-only) -- verified: packages/core/src/plugin-loader.ts#PluginLoader (class)";check-changeset-no-major --base origin/main: nomajor; its level axis reads the PR body, so it was run again with this body as the event payload (Verification).Skills-docs:line above is new.content/docs/plugins/index.mdxwas the one page teachingsignature: 'ed25519:…'on a plugin object, whichkernel.usenow refuses; it now says asignaturefield is not a verification by itself and showsrecordPluginArtifactVerdictbeforekernel.use. Thesignaturefield's own TSDoc onPluginMetadatasays the same. No skill underskills/**teaches it (searched). The full dogfood battery the arm owes ran once on this head (Verification).os plugin buildproduces carries code, so on one an unset tier is an omission, not that declaration; the boot-time loader refuses an installed plugin with no tier either way (runtime_undeclared, default-deny), and one with no entry module too." The spec changeset's sentence follows it. The pin is unchanged and green.test-budget:header is added from the seat's CI reading ofaf0b3888(Test Core 5/8, job 123319866423:serve-installed-plugins.integration.test.ts (7 tests) 27083ms):// test-budget: 40 — spawns the real producer once per single-cause fixture: …, 27.08 s × 1.25 rounded up. This round adds no case to that file.json-stdout-purity) is the devx holder's; nothing here changes it further.Review round 1: what changed
Head
a6a7e490→af0b3888, one push. Each seat-review item (r1, CHANGES), each contract-review item (PASS6487771335), and the two CI reds ona6a7e490.Seat review, must-fix
The stale-lock steal race (store). A writer that judged the lock stale used to unlink it by path, holding nothing, so two writers seeing the same stale lock could interleave: B unlinked the fresh lock A had just taken, and both ran the read-modify-write.
<lock>.steal(createdwx), and only if the lock file is still the one judged stale: same inode and same bytes. A steal lock left by a writer that died mid-clear is cleared the same way (by its age in round 1; by its pid since round 2, nit 5).{ pid, thread, token, at }with a freshrandomUUIDtoken, so two locks never share bytes. Release removes the lock only while it is still the holder's own file.installationsLockTestHooks.afterStaleJudged(a module seam, not exported from the package index) runs in the window between "judged stale" and "cleared". In that window, writer A's fresh lock replaces the stale one. Writer B then throwslocked by process <A>, does not run its function, and leaves A's lock byte for byte, with no.stealfile left behind. Anti-vacuity: the same test against the old unlink-by-path clear fails. Two more cases: a stale.stealis cleared, and a holder's release does not remove a lock that replaced its own.Per-record throws abort the boot. Each one is now a named refusal of that one plugin, and the next plugin still loads:
install_unreadable;rmSync, or the installations lock held past its wait) →uninstall_failed, with the record still marked so the next boot retries it;loader_failedline, and the boot goes on without installed plugins;namecannot be read →kernel_refused;records_unreadable.The corrupt disable-state file was worse than a throw. The package disable-state store reads a corrupt or unreadable file as
{}(deliberately, forAppPlugin), so the loader took it for "nothing disabled" and loaded plugins that had been disabled. Now the installed-plugin path reads that file strictly (loadDisabledPackageIdsStrict, new inpackage-state-store.ts):disabled_state_unreadable;os plugin disable | enablerefusedisable_state_failedand write nothing. Before, a disable rewrote the file holding one id, dropping every other disable.AppPlugin's lenient read is unchanged.Tests: the fault cases run against the real loader with
node:fsfaults injected per call (the slice-1plugin-install.test.tsprecedent). They cover a listing that fails, a file that vanishes, anrmSyncthat fails (the next boot then finishes), and a live lock held past a 200 ms test wait (installationsLockTestHooks.waitMs). The corrupt disable-state file is a real file, both at the boot and through both commands.Seat review, should-fix
Uninstall acted on an unlocked snapshot. Now it runs under the installations lock and re-reads the record first. It removes and drops only while the record is still
uninstall-pendingwith the samedirand artifact sha. Otherwise the result isrecord_changed, and nothing is removed or loaded. Test: an id installed again inside the window between the records read and the uninstall keeps its fresh directory and record, and the next boot loads it.What the import resolves. Confirmed: a plugin built by
os plugin buildfailed. The new end-to-end case buildscom.acme.builtwithos plugin buildfrom a TypeScript source that imports@objectstack/core, signs it, installs it and boots. Without the fix it was refused:import_failed: … Cannot find package '@objectstack/core' imported from …/plugins/env_local/com.acme.built/1.0.0/dist/index.mjs(run with the one wiring line removed, then restored).The fix: a resolve hook registered with
module.register(adata:module, no file on disk). It resolves a bare@objectstack/*specifier imported from inside a verified install directory as if the host file passed in (resolveHostPackagesFrom: import.meta.urlininstalled-plugins-boot.ts) had imported it. With it, the plugin loads, and itsinitreads, through its own import of@objectstack/core, the verdict the host recorded. That proves it got the host's module instance, not a copy. The resolution boundary and the lazy-import window are under Known limitations.The core narrowing is now stated. The changeset carries a Migration paragraph:
plugin.signatureand callingkernel.use;recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 })after your own artifact verification, or leavingsignatureunset.The Clause-② weighing is under Acceptance notes.
"Nothing of it runs" was false after the import. It is reworded in
installed-plugins-boot.ts, the loader header and the changeset. A plugin refused before its import had nothing evaluated. One refused after it (import_failed,not_a_plugin,signature_conflict,plugin_name_taken,kernel_refused) had its entry module's top-level code run, and is not registered.Off-surface files are listed under Acceptance notes, with why each was needed.
Seat review, nits. All taken.
manifest_mismatch).worker_threadof the same process is a live holder (test added).signaturefield from reading as verified, and that it is no boundary against in-process code, which can call the publicrecordPluginArtifactVerdict.readTar, and the reads before verification:readTarhonours the typeflag: regular files only, directories skipped, and a link, device, pax or GNU extension header refused by name.prefixonto the name, refuses a cut-short entry and an unreadable size, and returns views instead of copies. The inflate's input is no longer copied either.artifact_unreadable) and at boot (artifact_changed).os plugin uninstallno longer reports "Marked" from a stale record. A record gone under the lock isplugin_not_installed("removed while this command ran"). The lock error is matched by type (PluginInstallationsLockedError, exported), and the commands report it asinstallations_locked. It carries nocodefield: acodestamped in@objectstack/runtime'sdistmust be an ADR-0112 ledger code (check:dispatcher-error-vocabulary, which flagged the first cut of this round), and this is a CLI refusal, not a wire code. Any other failure isinstall_failed/uninstall_failed, not mislabelled as a busy lock.Contract review
runtimedescribes now say "at boot for a pluginos plugin installinstalled". The pin probeSAYS_BOOT_LOADS_ONLY_NODEmoved with them, and the other probes still match (the pin is green). The five reference pages were regenerated.os plugin installinstalls (which carries code) it is an omission, refusedruntime_undeclared.packages/spec/liveness/README.mdL79, L302 and L1010 are taken in this PR. The rows stay as the historical worked example, each says the row flipped toliveon ADR-0025 M1 — local T0 install loop for first-party, self-signed plugins: os plugin install (signature + integrity, materialize, record), boot-time loader with the tier dispatch point, disable/uninstall, Stripe sample as acceptance #27123, and each says no row holdslive-elsewheretoday.test-budget:header is not added yet. Its precondition, a CI reading of the file, is not readable from this seat. The merged timing table in theTest Corelog lists only the slowest 20 files, cut at 57.88 s ona6a7e490, and this file was not among them. The per-shard capture artifacts download from a blob host this container's proxy refuses. The file also changed in this push (a build and a sixth install), so it needs a fresh reading anyway. Round 2 adds it from the seat's reading.The two CI reds on
a6a7e490(Test Core 2/8 and 6/8). Both came from this PR's liveness flip. Theruntimerow was the onlylive-elsewhererow, and two suites asserted on it in the shipped ledger:@objectstack/specscripts/liveness/check-liveness.test.ts:live-elsewherecriteria block mutatesmanifest/runtimeand expected it to carry the verdict.manifestcount shard is moved by that one row.@objectstack/lintsrc/lint-liveness-properties.test.ts. Four real-ledger assertions needed a shippedlive-elsewhererow:liveand not warned, with a guard that the manifest ledger was read.live-elsewhererow with no opt-in, through the walk itself.live-elsewhererow to exist.My earlier local run covered only spec's
src/kernel; this round ran the full spectestproject and the lint suite.What changes
The boot-time loader (
packages/runtime/src/installed-plugin-loader.ts, new;loadInstalledPlugins,verifyInstalledPlugins,dispatchPluginRuntime). It reads this project's installation records (loadPluginInstallations(env)). For each record, in this order, the first failing check refuses that plugin by name, before anything of it is imported:install_dir_unexpected: the record'sdirmust be the onepluginInstallDir(env, id, version)derives. This runs before the uninstall and disable branches, so nothing is read from, or removed under, any other path.Disabled (
os plugin disable): skipped before anything is read. This is not a refusal. A disable-state file that is there and cannot be read refuses each plugin instead (disabled_state_unreadable).The kept artifact:
install_dir_missing, andartifact_changedwhensha256(artifact.osplugin)is not the recorded sha, or the file is larger than any artifact may unpack to (it is then not read). This closes slice 1's shared-ownership limitation: a tree another project replaced is refused here.The signature, over the raw kept bytes, before anything is inflated:
signature_missing,signature_malformed;signature_key_mismatchwhen the.signames a key id other than the record's;sys_trusted_key:signature_key_untrusted,signature_key_revoked,trusted_key_invalid, andtrusted_key_changedwhen the fingerprint is not the one the install verified;verifyPluginArtifactwithrequirePlatform: false:signature_invalid. The signature covers the bytes only.package_idandversionfeed only the platform counter-signature, which is not required here; what ties the bytes to the record's id and version is check 4's manifest comparison.A key revoked since install therefore refuses the plugin at its next boot. A key read that throws refuses every signed record
trusted_keys_unreadable; unsigned records are still checked. AtrustUnsignedrecord skips this step and loads with a warning on every boot; an unsigned record without that flag is refused asunsigned_without_override.The tree is the artifact: the bounded
readTarGz(artifact_unreadable). Every file in the directory must be byte-equal to its entry, with no extra file and no symbolic link beside the two kept files (tree_changed). The manifest's id and version must be the record's (manifest_mismatch).The tier dispatch point (
dispatchPluginRuntime, ADR-0025 §3.6):nodeimports<dir>/<manifest.main>(defaultdist/index.mjs, which must be a file of the artifact, elseentry_missing) in process throughmaterializeStackPlugin, the rule a stack'spluginsentries take;sandboxandworkerare refused asruntime_not_dispatcheduntil M3, with no in-process fallback;runtime_undeclared(default-deny).A check that throws on one record (a file it cannot read, one that vanished mid-walk) refuses that record
install_unreadable; one installation never fails the boot.After the import.
import_failed,not_a_plugin(a plain bundle is wrapped inAppPlugin; reading the export throwing isnot_a_plugintoo) andsignature_conflictcome after the entry module's top-level code ran: the plugin is not registered, but its module was evaluated.signature_conflictcovers three cases: an unsigned install whose module carries asignature, a module that carries a signature other than the verified one, or a frozen plugin object the verified signature cannot be pinned to.What the import resolves.
os plugin buildleaves@objectstack/*external, and an install directory underOS_HOMEhas nonode_modulesholding them. So when the host passesresolveHostPackagesFrom(a file URL of its own), the loader registers a resolve hook once per (host, install directory) withmodule.register. A bare@objectstack/*specifier imported from inside a verified install directory then resolves as if the host file had imported it, which gives the same module instances the host's kernel runs on. An install directory is matched by its real path too (Node names an importing module by it), so anOS_HOMEbehind a symbolic link resolves. Only a specifier that names a package under@objectstack/is redirected: one with an empty,.or..segment, a%or a backslash is not. Every other specifier resolves as Node resolves it.loadInstalledPluginsalso finishes uninstalls. For a record markeduninstall-pending, it works under the installations lock and re-reads the record. Only while the record is still the marked one, with the same directory and sha, does it remove the directory (and the empty parent) and drop the record and its disable flag. The directory is kept if another project's installations file for the same home and environment still names it. Otherwise it answersrecord_changed, and a removal that throws isuninstall_failed.Serve wiring (
packages/cli/src/commands/serve.ts+packages/cli/src/utils/installed-plugins-boot.ts, new).registerInstalledPluginsis called after the configpluginsloop and before bootstrap, becauseObjectKernel.usethrows after bootstrap. When there is no installations file it returnsnulland reads nothing, so the boot is what it was. Otherwise it does the following:readTrustedKeys(below), againstOS_DATABASE_URLor the project database, and only when a signed record needs them.import.meta.urlasresolveHostPackagesFrom.kernel.uses each plugin that passed, so dependency order and health checks stay the kernel's.plugin_name_taken, whenkernel.hasPlugin(name): the kernel's same-name rule would let an installed plugin replace one the host already registered;kernel_refused, whenkernel.usethrows or the plugin's name cannot be read;loader_failed, one line, when the loader itself throws; the boot goes on without installed plugins.✗ Refused installed plugin id@version (code): messageline per refusal, plus dim lines for the disabled and uninstalled ones, and the banner gains⚠ N installed plugins were refused and not loaded(ServerReadyOptions.installedPluginsRefused).A refused plugin does not stop the boot, following the config
pluginsloop's precedent.os devandos startreach this through serve.The kernel's signature check is real (
packages/core/src/plugin-loader.tsverifyPluginSignature+packages/core/src/security/plugin-artifact-verdict.ts, new).signatureon a plugin object passed.{ signature, keyId, fingerprint, artifactSha256 }against the plugin object (recordPluginArtifactVerdict, aWeakMap). The loader refuses a plugin whosesignaturehas no verdict or a different one: "…carries a signature (keyId=…) that no host verified against its artifact … asignaturefield alone does not make a plugin signed."signaturefield reading as verified. It is not a boundary against code already running in the process:recordPluginArtifactVerdictis public, and in-process code (anode-tier plugin's) can call it.signature(measured).Clause-②: no (narrowing), the BREAKING banner, an ADR-0087runtime-interface-onlydisposition and the Migration paragraph.PluginMetadata.signature's TSDoc andcontent/docs/plugins/index.mdxno longer teach setting the field by hand.The archive reader is in core (
packages/core/src/security/plugin-artifact-archive.ts, new).readTar,readTarGzandsha256Hexmoved frompackages/cli/src/utils/osplugin.ts, which re-exports them, so CLI callers do not change.gunzipSync'smaxOutputLength(MAX_UNPACKED_ARTIFACT_BYTES, 256 MiB). An artifact past the cap is refused as "the artifact inflates past N bytes, the most an .osplugin may unpack to" once the output reaches the cap. This closes slice 1's "no size cap" limitation.readTartakes regular files (typeflag0/ NUL) and skips directories (5). Any other entry is refused, naming it: a link, a device, or a pax or GNU extension header.prefix(magicustar\0only) onto the name, and refuses a cut-short entry and an unreadable size.os plugin install(packages/cli/src/commands/plugin/install.ts) now runs in this order:MAX_UNPACKED_ARTIFACT_BYTESbefore reading it (artifact_unreadable), and the.sig;signature_missingunless--trust-unsigned, thensignature_malformed;readTrustedKeys;verifyPublisherSignatureover the raw bytes;readTarGz(bounded) and the slice-1 entry, manifest and integrity checks;install_dir_taken, materialize and record, all insidewithPluginInstallationsLock.A busy lock is refused as
installations_locked(matched by type,PluginInstallationsLockedError); any other failure of the lock itself isinstall_failed. All slice-1 refusal codes are unchanged; only their order moved, so a bad signature is now named before a bad archive. An artifact whose tier the boot will refuse installs with a warning: "X is installed, and the next boot will refuse to load it (runtime_not_dispatched): …". The--jsonoutput gainsboot: { loads, error }. The "os servedoes not load installed plugins yet" line is gone.readTrustedKeys(packages/cli/src/utils/plugin-trust-read.ts, new) is the one read of the trusted keys, shared by install and the boot loader. It is a read-only one-shotbootSchemaStack(deferSchemaDdl+readOnlyProbe) for all key ids at once. A table the boot measured absent answers every idunknown. It replaces install's private resolver.New commands (
packages/cli/src/commands/plugin/{disable,enable,uninstall}.ts), each with--json:os plugin disable ID/os plugin enable IDuse the package disable-state store under the plugin environment id (env_localunlessOS_ENVIRONMENT_ID; the store's own default isdefault, so the commands pass it explicitly). They write nothing when the state is already the one asked for (changed: false). On a disable-state file they cannot read they refusedisable_state_failedand write nothing.os plugin uninstall IDmarks the recorduninstall-pendingunder the lock; the next boot does the removal. A record removed while the command ran isplugin_not_installed.Each refuses by name an id this project has not installed (
plugin_not_installed, naming what is installed, or "nothing is installed") and an unreadable records file (record_unreadable).disable/enablerefuse an id marked for uninstall (plugin_uninstall_pending); a seconduninstallchanges nothing; foruninstall, a busy lock isinstallations_lockedand any other failureuninstall_failed. Installing the plugin again before the next boot cancels the uninstall.The installations-file lock (
packages/runtime/src/plugin-installation-store.ts). I chose a lock over append-only, so slice 1's file format stays and nothing migrates.withPluginInstallationsLock(env, fn)creates<installations file>.lockwithopen(…, 'wx'), holding{ pid, thread, token, at }.recordPluginInstallationtakes it inside install's own hold), and released infinally, only while the lock file is still the holder's.How a stale lock from a crashed process is cleared: the next writer clears it, with no manual step, when any of these holds:
process.kill(pid, 0), whereEPERMcounts as alive);INSTALLATIONS_LOCK_STALE_MS), which covers a reused pid;worker_threadof the same process is a live holder);The clear holds
<lock>.stealand removes the lock only if it is still the file judged stale (same inode, same bytes), so two writers judging the same lock stale cannot both take it. A steal lock is judged as the lock is (its pid, its age): a dead writer's is cleared, and a live writer's is waited for, with a sleep between tries. A lock path that is there and cannot be read (a directory, an unreadable file) is refused at once, naming it. Otherwise the writer polls for up to 10 s (INSTALLATIONS_LOCK_WAIT_MS), then throwsPluginInstallationsLockedError, which the commands report asinstallations_locked: "The plugin installations file X is locked by process N (since T), still running after 10s. Let that command finish; if noos plugincommand is running, remove X.lock."Self-descriptions (
packages/spec):packages/spec/src/kernel/manifest.zod.ts: the.describe()and TSDoc ofPluginRuntimeSchemaand the manifest'sruntimefield now say the tier is enforced at the publish gate and at boot for a pluginos plugin installinstalled. Onlynodeis loaded, in process and not isolated;sandboxandworkerare refused until M3. The field TSDoc adds that an unset tier on an artifactos plugin buildproduces is an omission, and that the loader refuses an installed plugin with no tier either way. The five generated reference pages that carry those strings were regenerated (check:generated --fix):content/docs/references/kernel/{manifest,package-registry}.mdxandcontent/docs/references/api/{package-api,package-api-assembled,protocol}.mdx.packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.tspins the new text and pins the retracted "load-side enforcement is NOT implemented" absent.packages/spec/liveness/manifest.json: theruntimerow moveslive-elsewhere→live(verifiedAt2026-10-29,in-repo, evidenceinstalled-plugin-loader.ts#dispatchPluginRuntime+install.ts#PluginInstall). The old note had named exactly this flip as the open half. The cloud half is kept as attributed history and was not re-read.liveness/state-counts/manifest.mdwas regenerated.packages/spec/liveness/README.mdL79 / L302 / L1010: the row stays as the worked example oflive-elsewhere, says it flipped toliveon ADR-0025 M1 — local T0 install loop for first-party, self-signed plugins: os plugin install (signature + integrity, materialize, record), boot-time loader with the tier dispatch point, disable/uninstall, Stripe sample as acceptance #27123, and says no row holds that status today.packages/spec/scripts/liveness/check-liveness.test.ts: thelive-elsewherecriteria cases plant the row (CI red 2/8, above).packages/cli/README.mdgains thedisable/enable/uninstallrows, and its paragraph describes boot re-verification and the tier rule.content/docs/protocol/kernel/lifecycle.mdxupdates the top callout, the Plugin Installation callout, What an author does today, and the Summary line.packages/cli/src/commands/plugin/trust/revoke.tsnow says the next boot refuses plugins signed by the revoked key.Changesets:
.changeset/27123-boot-time-plugin-loader.md(@objectstack/cli,@objectstack/runtime,@objectstack/core, patch), with the Migration paragraph for@objectstack/core;.changeset/27123-runtime-tier-enforced-at-boot.md(@objectstack/spec, patch, prose only).Both carry
Metadata-protocol: none. The first carriesClause-②: no (narrowing), the**BREAKING**banner andadr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader); the second carriesClause-②: no.Tests
packages/runtime/src/installed-plugin-loader.test.ts(55) runs on a real tmpOS_HOME, with artifacts built and signed in the test (its own tar.gz writer, a real Ed25519 key):nodeplugin loads and itsinitruns; an unsigned--trust-unsignedinstall loads; a plain bundle is wrapped; with no installations file, none is created;imported == []);records_unreadable; one refused record does not stop the next from loading;import_failed,not_a_pluginincl. an export whose read throws, threesignature_conflictshapes);dirnever removed; andverifyInstalledPluginsimporting nothing;install_unreadableon a failed listing and on a vanished file,disabled_state_unreadableon a corrupt file and the write refused over it,uninstall_failedon a failed removal then finished by the next boot,uninstall_failedon a lock held past the wait) andrecord_changed;throw undefinedand a getter throwingnull, each refused by name with the next plugin loading; an unreadable lock at boot (uninstall_failed, at once); the boot's artifact size cap (artifact_changed, never read); and, in a real Node child process, the symlinkedOS_HOMEand the climbing specifier.packages/runtime/src/plugin-installation-store.lock.test.ts(19):PluginInstallationsLockedError;.stealcleared, a release that leaves a replaced lock alone;a6a7e490), so the test fails without the lock;otherProjectsInstallationDirs.packages/core/src/security/plugin-artifact-verdict.test.ts(17):PluginLoaderandObjectKernel.use: a recorded verdict passes; no verdict, a different signature, and a malformed signature are refused; an unsigned plugin is unaffected;readTarreads regular files only (above).packages/cli/src/commands/plugin/install.integration.test.ts(22):signature_key_untrusted(it never reaches gunzip), and the same bytes with a trusted key and a valid signature are refusedartifact_unreadable;sandboxtier installs with the boot warning and a--jsonbootpayload;plugin_not_installed; disable / enable toggling, withchanged: falseon a repeat; uninstall markinguninstall-pendingand removing nothing yet, a repeat changing nothing,disablethen refused asplugin_uninstall_pending, and a re-install before the boot restoringinstalled;artifact_unreadableunread (a sparse file).packages/cli/src/utils/installed-plugins-boot.test.ts(6, new, round 2) runs the serve wiring against a stand-in loader and kernel: a loader throwing anError,undefinedornullis oneloader_failedline; a name getter throwingundefinedand akernel.usethrowingnullarekernel_refusedby name, and the next plugin is registered; a taken name isplugin_name_taken; no installations file reads nothing.packages/cli/src/commands/plugin/uninstall.test.ts(3, new, round 2): the record-gone race isplugin_not_installed, a busy lockinstallations_locked, any other failureuninstall_failed.packages/cli/test/serve-installed-plugins.integration.test.ts(7, new) runs a realos serve objectstack.config.ts --no-serverchild process (OS_MIGRATE_AND_EXIT=1). Six plugins are installed in process: a signednodeplugin, asandboxone, a tampered tree, a disabled one, one marked for uninstall, andcom.acme.built, built byos plugin buildfrom a TypeScript source importing@objectstack/core. Marker files written by each plugin'sinitshow whose code ran:nodeplugins ran;@objectstack/coreimport, equal to its record's;runtime_not_dispatchedandtree_changedare printed as✗lines by name, and neither plugin ran;Both boots exit 0 through
Migration complete(the control). A second boot, afterenableon the disabled plugin anduninstallon the loaded one, runs only the re-enabled plugin among the five, and removes the other, leaving exactly four records. Last,disableandenablerefuse a corrupt disable-state filedisable_state_failedand leave it byte for byte. The file carries a measuredtest-budget: 40header (round 2).packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts: thebootSchemaStackcaller is nowutils/plugin-trust-read.ts, which replacescommands/plugin/install.ts, run throughos plugin installwith the same no-write mode. The[#27123]anti-vacuity case uses the new key.packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts(4): the new probes, and the retracted sentence absent.packages/spec/scripts/liveness/check-liveness.test.tsandpackages/lint/src/lint-liveness-properties.test.ts: the CI-red fixes above.Acceptance notes
no (narrowing), per the contract review. Round 1 weighed it asno, reading Clause ② as the authorable accept set. The contract review (6490914228②) read it as the published-surface question it is (check-changeset-no-majorL914–L922: "does this diff widen or narrow a published accept set or public surface"), andkernel.use's accept set for a plugin carryingsignatureis narrowed for every out-of-repo host. So the line carries the arm, the changeset carries the BREAKING banner and theruntime-interface-onlydisposition, and the level stayspatchunderMetadata-protocol: none(the same docblock: a non-protocol narrowing "shipspatchand keeps its BREAKING banner and its ADR-0087 disposition unchanged"). In substance the narrowing makes ADR-0025 §3.7's load-side check real; no plugin in this repo sets the field.packages/cli/src/utils/installed-plugins-boot.ts(new): serve's wiring, kept out of the 4700-lineserve.tsso the call site is twelve lines; it holds the key read, the host codes and the printing.packages/cli/src/utils/format.ts:ServerReadyOptions.installedPluginsRefusedand its banner line.packages/cli/src/utils/plugin-install.ts:pluginEnvironmentId()andfindPluginInstallation(), shared by the three new commands.packages/core/src/security/plugin-artifact-verdict.ts(new) andpackages/core/src/security/index.ts: the verdict the kernel's loader reads, and the exports of it and of the archive reader.packages/runtime/src/index.ts: the loader, the store's new functions,PluginInstallationsLockedErrorand the lock constants.packages/runtime/src/package-state-store.ts(loadDisabledPackageIdsStrict, for the fail-open disable read), and the CI-red fixes inpackages/spec/scripts/liveness/check-liveness.test.tsandpackages/lint/src/lint-liveness-properties.test.ts. The fixes come under the seat's authorization to take derivative work that blocks this card (04:21Z); they change test fixtures only, and no gate's behaviour. Also in round 1:packages/spec/liveness/README.md, taken in at the contract review's request.content/docs/plugins/index.mdx(the one page that taught the narrowed field, for theSkills-docs:line) and two unit-test files for the seat's nit 7,packages/cli/src/utils/installed-plugins-boot.test.tsandpackages/cli/src/commands/plugin/uninstall.test.ts.plugin-trust-read.ts) rather than a second private copy in serve. The one-shot family pin'sCALLERSkey moved there fromcommands/plugin/install.ts, still run throughos plugin install, so no new command is needed for the pin.plugin_name_taken,kernel_refused,loader_failed),signature_conflict, and round 1'sinstall_unreadable,disabled_state_unreadable,uninstall_failed,record_changedanddisable_state_failedare new, not in the plan.verifyPublisherSignatureover the raw bytes, because the manifest's id and version are not read yet at that point. The boot loader usesverifyPluginArtifactover the kept bytes, and binds them to the record's id and version through the manifest comparison.manifest-depsretirement, native-addon refusal) and item 6 (the cloudconnector-stripee2e) go to slice 3. Noos plugin buildedit, nodocs/adr/**, nothing read or edited in objectstack-ai/cloud.packages/specreach: the describe text and TSDoc of theruntimetier, its pin, the liveness grade (manifest.json+ regeneratedstate-counts/manifest.md), the liveness README's three passages about that row, and the liveness gate's self-test fixtures. Nothing else underpackages/specchanged.@objectstack/specchanges:node,sandboxandworkerstill parse, and an unset tier is still legal at parse time. What changes is what the local boot does with them.Known limitations
@objectstack/*specifier that names a package (no empty,.or..segment, no%or backslash), imported from inside a verified install directory (matched by its path and its real path), is redirected to the host's packages. Every other bare specifier resolves as Node resolves it from that directory: underOS_HOMEthat normally finds nothing, so a plugin that leaves any other dependency external failsimport_failedby name (os plugin build'sbundledpackaging bundles them). The hook is amodule.registerloader hook; on a runtime without it, registration is skipped and such an import fails the same way.OS_HOME, which this design trusts as much as the code it runs.plugin_name_taken). A host plugin registered after it, under the same name, supersedes the installed one with the kernel's warning, so the host wins either way. Making that a refusal too means moving the installed-plugin registration after every host registration inserve.ts, which is not worth its blast radius in this slice.nodeis not isolation. An installednodeplugin is author code in the host process with a fullPluginContext. The signature proves who built it, not what it does, and in-process code can callrecordPluginArtifactVerdictitself.sandboxandworkerare refused rather than run, until M3.process.kill(pid, 0)on this machine. AnOS_HOMEshared across machines over a network filesystem could see a remote live holder's pid as dead here and clear its lock. The documented layout is one machine.Atomics.wait, up to 10 s, per uninstall finished at boot too). The callers are one-shot CLI commands and the boot before the kernel starts.@objectstack/core. The resolve hook gives an installed plugin the host's instance, and the serve test proves it for the built plugin. A host that loaded two copies of core itself would have its own loader refuse what the other verified, which fails closed, askernel_refusedby name.uninstall-pending.Verification
At head
766b96ff(baseorigin/main10f27b65;origin/mainis now7614f8f0, 41 commits on, and merges without conflict. None of those commits adds alive-elsewhererow to any ledger).Round 2, under
scripts/pm/os-verify-lock.sh(slotissue-27123,NODE_OPTIONS=--max-old-space-size=4096,--maxWorkers=2), run one after another on the round's commit. Every VERDICT wascommand-exit 0:@objectstack/corebuild and@objectstack/runtimebuild;@objectstack/runtimetest: Test Files 473/473, Tests 7739 passed, 19 skipped (round 1: 7729; the 10 are the loader's 7 and the lock's 3);@objectstack/coretest: Test Files 125/125, Tests 2825/2825;@objectstack/specplugin-runtime-tier-truthful-text: Tests 4/4;--project unit: Test Files 348/348, Tests 5047/5047 (round 1: 346 and 5038; the difference is the two new files and their 9 tests, and the tiers partition pin is green with them);--project integrationoninstall.integration,serve-installed-plugins.integrationandschema-migrate.one-shot-family.integration: Test Files 3/3, Tests 153/153;typecheckof runtime, cli and core (each withcheck:test-typecheck): clean, each VERDICTcommand-exit 0andcheck:test-typecheckOK against its ledger.Round 1's full runs at
af0b3888(lint Tests 7570/7570; spec Tests 22358 passed, 1 todo; cli--project integrationTests 1547 passed, 9 skipped) are not repeated: round 2 changes no spec key or schema, no lint source, and no cli integration file but the two run above.The dogfood battery the narrowing arm owes, once on this head, with no
OS_TEST_SHARDand no path:pnpm --filter '@objectstack/dogfood^...' build(VERDICTcommand-exit 0), then under the lockpnpm --filter @objectstack/dogfood test: VERDICTcommand-exit 0(held the lock 25m20s), Test Files 348 passed, 3 skipped (351), Tests 2806 passed, 20 skipped (2826), no failure.Anti-vacuity, each run once and restored (round 2's; round 1's stand):
timeout 90→ exit 143).OS_HOMEtest fails.startsWith('@objectstack/')match: the climbing-specifier plugin loads.(error as Error).messageread: the twothrow undefined/ getter-nulltests fail.Other checks:
check-adr-0087-registration --base origin/mainon the round's commit: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", the one verifiedpackages/core/src/plugin-loader.ts#PluginLoader (class).check-changeset-no-major --base origin/main, run with this body as thepull_requestevent payload so its level axis reads the PR'sClause-②andMetadata-protocollines: nomajor, and LEVEL AXIS ✓ ("this PR declares clause-②no (narrowing)and metadata-protocolnone, and it introduces no changeset entry gradedminoror above and moves no metadata-protocol baseline — a non-protocol change atpatch"), exit 0.pnpm --filter @objectstack/spec check:generated: green (after--fixrebuilt the spec dist the TSDoc edit had made stale; no generated file changed).--no-inline-config) over the round's touched TypeScript files: exit 0.Gates:
node scripts/pm/dispatch-gates.mjs --ran …on the round's commit: 113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN, every command exit 0.Generated by Claude Code