Skip to content

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

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-02c5oc
Oct 29, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Requested by Bill · project thread
Part of #27123

Ruling-ref: 6467932167
Clause-②: no (narrowing) — @objectstack/core's PluginLoader (so every kernel.use) refuses a plugin object whose signature no host recorded a verdict for, where any well-formed signature passed. No key or accepted value of @objectstack/spec changes: it changes only the runtime tier's .describe() text and TSDoc (the generated reference pages carrying it regenerated), its text pin, the liveness runtime row, the liveness README's account of that row, and the liveness gate's self-test
Metadata-protocol: none
Skills-docs: updated here — content/docs/plugins/index.mdx, the one page that taught setting signature on a plugin object by hand, now shows recordPluginArtifactVerdict before kernel.use; no skill under skills/** 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 签名检查不再只看格式,必须有宿主记录的验证结论。
  • 为什么改: 这是 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 裁决 B′ 的第 2 片(ADR-0025 §3.5 M1)。第 1 片之后插件能装上,但没有任何东西加载它;core 对插件对象自带的 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 层和被篡改的插件按名拒绝,代码没跑;已禁用的跳过;已卸载的目录被删除。
  • 需要你做的: 需要你批准合并。本 PR 改动 4711 行,超过 3000 行的人工合并线,按 Tier H 落地。上一版留给你定的 liveness README 三处(第 79、302、1010 行),按契约审查的意见已在本 PR 里改掉,不再需要你裁决。core 签名检查的收窄,契约审查判定属于 Clause-② 意义上的收窄,本 PR 已照此声明(理由在 Acceptance notes),请你知悉。

Review round 2: what changed

Head af0b3888 → 766b96ff, one push. Each seat-review item (r2, CHANGES) and each contract-review item (FAIL 6490914228).

Seat review, must-fix

  1. A lock path that cannot be read was retried forever. The lock file reader took any stat or read error for "no lock", so a <installations>.lock that was a directory (EEXIST on create, EISDIR on read) or unreadable (EACCES, for example one left by a sudo os plugin install under umask 077) made the writer loop with no sleep and no deadline. os serve hung at boot while an uninstall was pending, and install / uninstall hung too.
    • Now only ENOENT reads as "no lock". Any other failure refuses at once, naming the path: "The plugin installations lock X cannot be read (EISDIR …). If no os plugin command is running, remove it." At boot that is uninstall_failed for the one plugin, and the next plugin still loads; the commands report it as install_failed / uninstall_failed.
    • A release whose own lock can no longer be read leaves it, and what the command did stands.
    • Tests: a directory and an EACCES lock (the second injected through the node:fs seam, 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 uninstall uninstall_failed naming 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

  1. The hook's roots are matched by real path. Node names an importing module by its real path, so an OS_HOME reached through a symbolic link (the macOS temp dir is one) never matched a root built with pathToFileURL(dir), and every built plugin failed import_failed. The roots now hold both dir and realpathSync(dir).

  2. A specifier that climbs out of @objectstack/ is not redirected. @objectstack/../zod/package.json from the host is any file the host can resolve (measured: it resolves to node_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 an OS_HOME that is a symbolic link, loads and calls sha256Hex. A plugin importing @objectstack/../zod/package.json is refused import_failed. Anti-vacuity: without the real path, the first test fails; with the old startsWith('@objectstack/') match, the second plugin loads.

  3. A throw of any value is named. A module whose top level does throw undefined (or a getter that throws null) made (error as Error).message throw inside the catch, which rejected the whole loader as loader_failed and 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: two it.each rows (throw undefined → import_failed, a getter throwing null → not_a_plugin), a boot where the next plugin still loads, and, at the serve wiring, a loader throwing an Error, undefined or null (one loader_failed line each), a name getter throwing undefined and a kernel.use throwing null (kernel_refused each, the next plugin registered). Anti-vacuity: with the old message read, the two loader tests fail.

Seat review, nits. All taken.

  1. The steal lock is judged by its pid, as the lock is, not by age alone (INSTALLATIONS_LOCK_STEAL_STALE_MS is 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.
  2. A writer that finds the steal lock held sleeps between tries (50 ms, the lock's own poll), and refuses at the deadline with its own message: "… has a stale lock (…) that another writer was still clearing after 10s. If no os plugin command 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).
  3. The missing pins:
    • the artifact size caps: a sparse kept artifact one byte over MAX_UNPACKED_ARTIFACT_BYTES is refused artifact_changed at boot with no read of it (counted through the node:fs seam), and a sparse artifact file of that size is refused artifact_unreadable by os plugin install;
    • loader_failed at 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 is plugin_not_installed ("removed while this command ran"), not "Marked"; a busy lock is installations_locked, any other failure uninstall_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)

  • ② must-fix: the core narrowing is declared. The changeset and this body now carry 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: no major; its level axis reads the PR body, so it was run again with this body as the event payload (Verification).
  • The narrowing's other carriers. The Skills-docs: line above is new. content/docs/plugins/index.mdx was the one page teaching signature: 'ed25519:…' on a plugin object, which kernel.use now refuses; it now says a signature field is not a verification by itself and shows recordPluginArtifactVerdict before kernel.use. The signature field's own TSDoc on PluginMetadata says the same. No skill under skills/** teaches it (searched). The full dogfood battery the arm owes ran once on this head (Verification).
  • Should-fix: the field TSDoc's premise. It now reads "An artifact os plugin build produces 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.
  • The test-budget: header is added from the seat's CI reading of af0b3888 (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.
  • ③.16 (nightly-tiers: red on main #27114, 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 (PASS 6487771335), and the two CI reds on a6a7e490.

Seat review, must-fix

  1. 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.

    • Now a stale lock is cleared only while holding <lock>.steal (created wx), 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).
    • Every lock carries { pid, thread, token, at } with a fresh randomUUID token, so two locks never share bytes. Release removes the lock only while it is still the holder's own file.
    • Test: 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 throws locked by process <A>, does not run its function, and leaves A's lock byte for byte, with no .steal file left behind. Anti-vacuity: the same test against the old unlink-by-path clear fails. Two more cases: a stale .steal is cleared, and a holder's release does not remove a lock that replaced its own.
  2. Per-record throws abort the boot. Each one is now a named refusal of that one plugin, and the next plugin still loads:

    • a throw while reading the install directory, i.e. EACCES on the walk or a file vanishing between the walk and its read → install_unreadable;
    • a failed uninstall (rmSync, or the installations lock held past its wait) → uninstall_failed, with the record still marked so the next boot retries it;
    • a loader that throws outright, at the serve wiring → one loader_failed line, and the boot goes on without installed plugins;
    • a plugin whose name cannot be read → kernel_refused;
    • an installations-file entry that is not a record → 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, for AppPlugin), 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 in package-state-store.ts):

    • the boot refuses each plugin disabled_state_unreadable;
    • os plugin disable | enable refuse disable_state_failed and 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:fs faults injected per call (the slice-1 plugin-install.test.ts precedent). They cover a listing that fails, a file that vanishes, an rmSync that 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

  1. 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-pending with the same dir and artifact sha. Otherwise the result is record_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.

  2. What the import resolves. Confirmed: a plugin built by os plugin build failed. The new end-to-end case builds com.acme.built with os plugin build from 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 (a data: 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.url in installed-plugins-boot.ts) had imported it. With it, the plugin loads, and its init reads, 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.

  3. The core narrowing is now stated. The changeset carries a Migration paragraph:

    • FROM: setting plugin.signature and calling kernel.use;
    • TO: recordPluginArtifactVerdict(plugin, { signature, keyId, fingerprint, artifactSha256 }) after your own artifact verification, or leaving signature unset.

    The Clause-② weighing is under Acceptance notes.

  4. "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.

  5. Off-surface files are listed under Acceptance notes, with why each was needed.

Seat review, nits. All taken.

  1. The overstated binding is corrected everywhere: the signature covers the bytes, and what ties them to the record's id and version is the manifest comparison (manifest_mismatch).
  2. The own-pid stale clause now compares the thread id too. A lock naming another worker_thread of the same process is a live holder (test added).
  3. The verdict module and the loader message no longer say a plugin object "cannot vouch for itself". They say the check stops a signature field from reading as verified, and that it is no boundary against in-process code, which can call the public recordPluginArtifactVerdict.
  4. The one-way name guard is kept and documented. A later host plugin of the same name supersedes the installed one with the kernel's warning, so the host wins in both directions. Moving the registration was not worth its blast radius (see Known limitations).
  5. readTar, and the reads before verification:
    • readTar honours the typeflag: regular files only, directories skipped, and a link, device, pax or GNU extension header refused by name.
    • It joins a POSIX prefix onto 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.
    • The compressed artifact is size-checked before it is read, at install (artifact_unreadable) and at boot (artifact_changed).
    • Tests cover the prefix, the directory skip, the views, GNU-format prefix bytes, the four refused typeflags, truncation, and a bad size.
  6. os plugin uninstall no longer reports "Marked" from a stale record. A record gone under the lock is plugin_not_installed ("removed while this command ran"). The lock error is matched by type (PluginInstallationsLockedError, exported), and the commands report it as installations_locked. It carries no code field: a code stamped in @objectstack/runtime's dist must 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 is install_failed / uninstall_failed, not mislabelled as a busy lock.

Contract review

  1. Both runtime describes now say "at boot for a plugin os plugin install installed". The pin probe SAYS_BOOT_LOADS_ONLY_NODE moved with them, and the other probes still match (the pin is green). The five reference pages were regenerated.
  2. The field TSDoc now bridges the two sentences: an unset tier declares a pure-metadata package, and on an artifact os plugin install installs (which carries code) it is an omission, refused runtime_undeclared.
  3. packages/spec/liveness/README.md L79, L302 and L1010 are taken in this PR. The rows stay as the historical worked example, each says the row flipped to live on 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 holds live-elsewhere today.
  4. The 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 the Test Core log lists only the slowest 20 files, cut at 57.88 s on a6a7e490, 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. The runtime row was the only live-elsewhere row, and two suites asserted on it in the shipped ledger:

  • 2/8, @objectstack/spec scripts/liveness/check-liveness.test.ts:
  • 6/8, @objectstack/lint src/lint-liveness-properties.test.ts. Four real-ledger assertions needed a shipped live-elsewhere row:
    • The shipped half now pins the flip: the row is live and not warned, with a guard that the manifest ledger was read.
    • "The fuse, lit" is shown on a planted live-elsewhere row with no opt-in, through the walk itself.
    • COVERAGE now runs over the union of the shipped statuses and the graded verdicts, so a status no row carries today is still answered.
    • The grading pin uses a row of the old shape.
    • The tracker-id pin no longer requires a live-elsewhere row to exist.

My earlier local run covered only spec's src/kernel; this round ran the full spec test project 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:

  1. install_dir_unexpected: the record's dir must be the one pluginInstallDir(env, id, version) derives. This runs before the uninstall and disable branches, so nothing is read from, or removed under, any other path.

  2. 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).

  3. The kept artifact: install_dir_missing, and artifact_changed when sha256(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.

  4. The signature, over the raw kept bytes, before anything is inflated:

    • signature_missing, signature_malformed;
    • signature_key_mismatch when the .sig names a key id other than the record's;
    • the key resolved in sys_trusted_key: signature_key_untrusted, signature_key_revoked, trusted_key_invalid, and trusted_key_changed when the fingerprint is not the one the install verified;
    • then verifyPluginArtifact with requirePlatform: false: signature_invalid. The signature covers the bytes only. package_id and version feed 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. A trustUnsigned record skips this step and loads with a warning on every boot; an unsigned record without that flag is refused as unsigned_without_override.

  5. 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).

  6. The tier dispatch point (dispatchPluginRuntime, ADR-0025 §3.6):

    • node imports <dir>/<manifest.main> (default dist/index.mjs, which must be a file of the artifact, else entry_missing) in process through materializeStackPlugin, the rule a stack's plugins entries take;
    • sandbox and worker are refused as runtime_not_dispatched until M3, with no in-process fallback;
    • a manifest with no tier is refused as 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 in AppPlugin; reading the export throwing is not_a_plugin too) and signature_conflict come after the entry module's top-level code ran: the plugin is not registered, but its module was evaluated. signature_conflict covers three cases: an unsigned install whose module carries a signature, 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 build leaves @objectstack/* external, and an install directory under OS_HOME has no node_modules holding them. So when the host passes resolveHostPackagesFrom (a file URL of its own), the loader registers a resolve hook once per (host, install directory) with module.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 an OS_HOME behind 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.

loadInstalledPlugins also finishes uninstalls. For a record marked uninstall-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 answers record_changed, and a removal that throws is uninstall_failed.

Serve wiring (packages/cli/src/commands/serve.ts + packages/cli/src/utils/installed-plugins-boot.ts, new). registerInstalledPlugins is called after the config plugins loop and before bootstrap, because ObjectKernel.use throws after bootstrap. When there is no installations file it returns null and reads nothing, so the boot is what it was. Otherwise it does the following:

  • It reads the trusted keys once, through readTrustedKeys (below), against OS_DATABASE_URL or the project database, and only when a signed record needs them.
  • It passes its own import.meta.url as resolveHostPackagesFrom.
  • It kernel.uses each plugin that passed, so dependency order and health checks stay the kernel's.
  • Host-side codes:
    • plugin_name_taken, when kernel.hasPlugin(name): the kernel's same-name rule would let an installed plugin replace one the host already registered;
    • kernel_refused, when kernel.use throws or the plugin's name cannot be read;
    • loader_failed, one line, when the loader itself throws; the boot goes on without installed plugins.
  • It prints one red ✗ Refused installed plugin id@version (code): message line 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 plugins loop's precedent. os dev and os start reach this through serve.

The kernel's signature check is real (packages/core/src/plugin-loader.ts verifyPluginSignature + packages/core/src/security/plugin-artifact-verdict.ts, new).

  • Before: any well-formed signature on a plugin object passed.
  • Now: the host that verified the artifact records a frozen verdict { signature, keyId, fingerprint, artifactSha256 } against the plugin object (recordPluginArtifactVerdict, a WeakMap). The loader refuses a plugin whose signature has no verdict or a different one: "…carries a signature (keyId=…) that no host verified against its artifact … a signature field alone does not make a plugin signed."
  • What this stops is a signature field reading as verified. It is not a boundary against code already running in the process: recordPluginArtifactVerdict is public, and in-process code (a node-tier plugin's) can call it.
  • A malformed signature is still refused first. A plugin without a signature is unaffected. No plugin in the repo sets signature (measured).
  • The narrowing is declared: Clause-②: no (narrowing), the BREAKING banner, an ADR-0087 runtime-interface-only disposition and the Migration paragraph. PluginMetadata.signature's TSDoc and content/docs/plugins/index.mdx no longer teach setting the field by hand.

The archive reader is in core (packages/core/src/security/plugin-artifact-archive.ts, new). readTar, readTarGz and sha256Hex moved from packages/cli/src/utils/osplugin.ts, which re-exports them, so CLI callers do not change.

  • The inflate is bounded by gunzipSync's maxOutputLength (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.
  • readTar takes regular files (typeflag 0 / NUL) and skips directories (5). Any other entry is refused, naming it: a link, a device, or a pax or GNU extension header.
  • It joins a POSIX prefix (magic ustar\0 only) onto the name, and refuses a cut-short entry and an unreadable size.
  • Neither the input nor the files are copied: a reader holds the compressed bytes plus the one inflated tar.

os plugin install (packages/cli/src/commands/plugin/install.ts) now runs in this order:

  1. read the bytes, refusing an artifact file larger than MAX_UNPACKED_ARTIFACT_BYTES before reading it (artifact_unreadable), and the .sig;
  2. signature_missing unless --trust-unsigned, then signature_malformed;
  3. resolve the key through readTrustedKeys;
  4. the key verdicts;
  5. verifyPublisherSignature over the raw bytes;
  6. only then readTarGz (bounded) and the slice-1 entry, manifest and integrity checks;
  7. records, install_dir_taken, materialize and record, all inside withPluginInstallationsLock.

A busy lock is refused as installations_locked (matched by type, PluginInstallationsLockedError); any other failure of the lock itself is install_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 --json output gains boot: { loads, error }. The "os serve does 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-shot bootSchemaStack (deferSchemaDdl + readOnlyProbe) for all key ids at once. A table the boot measured absent answers every id unknown. 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 ID use the package disable-state store under the plugin environment id (env_local unless OS_ENVIRONMENT_ID; the store's own default is default, 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 refuse disable_state_failed and write nothing.
  • os plugin uninstall ID marks the record uninstall-pending under the lock; the next boot does the removal. A record removed while the command ran is plugin_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 / enable refuse an id marked for uninstall (plugin_uninstall_pending); a second uninstall changes nothing; for uninstall, a busy lock is installations_locked and any other failure uninstall_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>.lock with open(…, 'wx'), holding { pid, thread, token, at }.
  • It is re-entrant within one thread (recordPluginInstallation takes it inside install's own hold), and released in finally, only while the lock file is still the holder's.
  • Every read-modify-write takes it: install, uninstall mark, uninstall finish, record drop.

How a stale lock from a crashed process is cleared: the next writer clears it, with no manual step, when any of these holds:

  • the pid it names is not a live process on this host (process.kill(pid, 0), where EPERM counts as alive);
  • it is older than 5 minutes (INSTALLATIONS_LOCK_STALE_MS), which covers a reused pid;
  • it names the writer's own pid AND thread while the writer holds nothing (another worker_thread of the same process is a live holder);
  • it cannot be parsed and is older than one second, so a lock caught mid-write by its creator is not taken for stale.

The clear holds <lock>.steal and 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 throws PluginInstallationsLockedError, which the commands report as installations_locked: "The plugin installations file X is locked by process N (since T), still running after 10s. Let that command finish; if no os plugin command is running, remove X.lock."

Self-descriptions (packages/spec):

  • packages/spec/src/kernel/manifest.zod.ts: the .describe() and TSDoc of PluginRuntimeSchema and the manifest's runtime field now say the tier is enforced at the publish gate and at boot for a plugin os plugin install installed. Only node is loaded, in process and not isolated; sandbox and worker are refused until M3. The field TSDoc adds that an unset tier on an artifact os plugin build produces 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}.mdx and content/docs/references/api/{package-api,package-api-assembled,protocol}.mdx.
  • packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts pins the new text and pins the retracted "load-side enforcement is NOT implemented" absent.
  • packages/spec/liveness/manifest.json: the runtime row moves live-elsewhere → live (verifiedAt 2026-10-29, in-repo, evidence installed-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.md was regenerated.
  • packages/spec/liveness/README.md L79 / L302 / L1010: the row stays as the worked example of live-elsewhere, says it flipped to live on 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: the live-elsewhere criteria cases plant the row (CI red 2/8, above).
  • packages/cli/README.md gains the disable / enable / uninstall rows, and its paragraph describes boot re-verification and the tier rule. content/docs/protocol/kernel/lifecycle.mdx updates the top callout, the Plugin Installation callout, What an author does today, and the Summary line. packages/cli/src/commands/plugin/trust/revoke.ts now 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 carries Clause-②: no (narrowing), the **BREAKING** banner and adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader); the second carries Clause-②: no.

Tests

  • packages/runtime/src/installed-plugin-loader.test.ts (55) runs on a real tmp OS_HOME, with artifacts built and signed in the test (its own tar.gz writer, a real Ed25519 key):

    • loading paths: a signed node plugin loads and its init runs; an unsigned --trust-unsigned install loads; a plain bundle is wrapped; with no installations file, none is created;
    • every refusal code in order, each asserting that the plugin's module was never imported (imported == []);
    • records_unreadable; one refused record does not stop the next from loading;
    • the import outcomes (import_failed, not_a_plugin incl. an export whose read throws, three signature_conflict shapes);
    • disable; uninstall removing the directory; the shared-directory guard keeping it for another project; a foreign dir never removed; and verifyInstalledPlugins importing nothing;
    • round 1: the per-record throws (install_unreadable on a failed listing and on a vanished file, disabled_state_unreadable on a corrupt file and the write refused over it, uninstall_failed on a failed removal then finished by the next boot, uninstall_failed on a lock held past the wait) and record_changed;
    • round 2: a top-level throw undefined and a getter throwing null, 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 symlinked OS_HOME and the climbing specifier.
  • packages/runtime/src/plugin-installation-store.lock.test.ts (19):

    • a live holder (the test's parent pid) makes a writer wait, then time out naming the holder and the lock path, as a PluginInstallationsLockedError;
    • the stale shapes (dead pid, aged, own pid and thread, aged unparseable) are cleared; a fresh unparseable lock and one naming another thread of this process are not;
    • round 1: the steal race (above, with its anti-vacuity control), a stale .steal cleared, a release that leaves a replaced lock alone;
    • round 2: a dead writer's steal lock cleared by its pid, a live writer's kept and waited for without spinning, and a directory or an unreadable lock refused at once by name;
    • re-entrancy, and release on throw;
    • two child processes, started on a shared barrier, each record 40 installations under the lock, and all 80 land. Anti-vacuity control: the same test against the store with the lock removed lost records (54/80 and 49/80 on two runs, measured on a6a7e490), so the test fails without the lock;
    • mark / drop / disable, and otherProjectsInstallationDirs.
  • packages/core/src/security/plugin-artifact-verdict.test.ts (17):

    • the verdict hook through a real PluginLoader and ObjectKernel.use: a recorded verdict passes; no verdict, a different signature, and a malformed signature are refused; an unsigned plugin is unaffected;
    • the inflate cap: 4 MiB of zeros under a 1 MiB cap is refused by name, with the control that the default cap reads it; non-gzip input throws the inflate's own error;
    • round 1: readTar reads regular files only (above).
  • packages/cli/src/commands/plugin/install.integration.test.ts (22):

    • verify-before-inflate: a non-gzip artifact with an untrusted signature is refused signature_key_untrusted (it never reaches gunzip), and the same bytes with a trusted key and a valid signature are refused artifact_unreadable;
    • the sandbox tier installs with the boot warning and a --json boot payload;
    • the lifecycle commands: plugin_not_installed; disable / enable toggling, with changed: false on a repeat; uninstall marking uninstall-pending and removing nothing yet, a repeat changing nothing, disable then refused as plugin_uninstall_pending, and a re-install before the boot restoring installed;
    • round 2: an artifact file one byte over the unpack cap, refused artifact_unreadable unread (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 an Error, undefined or null is one loader_failed line; a name getter throwing undefined and a kernel.use throwing null are kernel_refused by name, and the next plugin is registered; a taken name is plugin_name_taken; no installations file reads nothing.

  • packages/cli/src/commands/plugin/uninstall.test.ts (3, new, round 2): the record-gone race is plugin_not_installed, a busy lock installations_locked, any other failure uninstall_failed.

  • packages/cli/test/serve-installed-plugins.integration.test.ts (7, new) runs a real os serve objectstack.config.ts --no-server child process (OS_MIGRATE_AND_EXIT=1). Six plugins are installed in process: a signed node plugin, a sandbox one, a tampered tree, a disabled one, one marked for uninstall, and com.acme.built, built by os plugin build from a TypeScript source importing @objectstack/core. Marker files written by each plugin's init show whose code ran:

    • only the node plugins ran;
    • the built plugin's marker holds the artifact sha256 of the verdict it read through its own @objectstack/core import, equal to its record's;
    • runtime_not_dispatched and tree_changed are printed as ✗ lines by name, and neither plugin ran;
    • the disabled plugin is reported as disabled and did not run;
    • the uninstalled directory is gone and its record dropped.

    Both boots exit 0 through Migration complete (the control). A second boot, after enable on the disabled plugin and uninstall on the loaded one, runs only the re-enabled plugin among the five, and removes the other, leaving exactly four records. Last, disable and enable refuse a corrupt disable-state file disable_state_failed and leave it byte for byte. The file carries a measured test-budget: 40 header (round 2).

  • packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts: the bootSchemaStack caller is now utils/plugin-trust-read.ts, which replaces commands/plugin/install.ts, run through os plugin install with 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.ts and packages/lint/src/lint-liveness-properties.test.ts: the CI-red fixes above.

Acceptance notes

  • Size: Tier H. 4711 changed lines (44 files, +4319 / −392, generated included), against the 3000 human-merge threshold. Most of the growth in rounds 1 and 2 is tests.
  • Clause-② on the core narrowing: no (narrowing), per the contract review. Round 1 weighed it as no, reading Clause ② as the authorable accept set. The contract review (6490914228 ②) read it as the published-surface question it is (check-changeset-no-major L914–L922: "does this diff widen or narrow a published accept set or public surface"), and kernel.use's accept set for a plugin carrying signature is narrowed for every out-of-repo host. So the line carries the arm, the changeset carries the BREAKING banner and the runtime-interface-only disposition, and the level stays patch under Metadata-protocol: none (the same docblock: a non-protocol narrowing "ships patch and 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.
  • Off-surface files (deviations from the Claim's surface), each named with why:
    • packages/cli/src/utils/installed-plugins-boot.ts (new): serve's wiring, kept out of the 4700-line serve.ts so the call site is twelve lines; it holds the key read, the host codes and the printing.
    • packages/cli/src/utils/format.ts: ServerReadyOptions.installedPluginsRefused and its banner line.
    • packages/cli/src/utils/plugin-install.ts: pluginEnvironmentId() and findPluginInstallation(), shared by the three new commands.
    • packages/core/src/security/plugin-artifact-verdict.ts (new) and packages/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, PluginInstallationsLockedError and the lock constants.
    • Round 1 adds packages/runtime/src/package-state-store.ts (loadDisabledPackageIdsStrict, for the fail-open disable read), and the CI-red fixes in packages/spec/scripts/liveness/check-liveness.test.ts and packages/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.
    • Round 2 adds content/docs/plugins/index.mdx (the one page that taught the narrowed field, for the Skills-docs: line) and two unit-test files for the seat's nit 7, packages/cli/src/utils/installed-plugins-boot.test.ts and packages/cli/src/commands/plugin/uninstall.test.ts.
  • Deviations from the posted plan, each inside the Claim's surface:
    • The trust read became a shared util (plugin-trust-read.ts) rather than a second private copy in serve. The one-shot family pin's CALLERS key moved there from commands/plugin/install.ts, still run through os plugin install, so no new command is needed for the pin.
    • The host-side codes (plugin_name_taken, kernel_refused, loader_failed), signature_conflict, and round 1's install_unreadable, disabled_state_unreadable, uninstall_failed, record_changed and disable_state_failed are new, not in the plan.
    • The banner counts only refusals; loaded plugins appear in its plugin list like any other.
    • The stale-lock rule has the four clauses above and the steal lock, not only "pid dead".
    • Install verifies with verifyPublisherSignature over the raw bytes, because the manifest's id and version are not read yet at that point. The boot loader uses verifyPluginArtifact over the kept bytes, and binds them to the record's id and version through the manifest comparison.
    • The host-package resolve hook (round 1, should-fix 4).
  • Not in this slice, per the Go: the i18n-extract config for the deployment-level objects; card item 4 (manifest-deps retirement, native-addon refusal) and item 6 (the cloud connector-stripe e2e) go to slice 3. No os plugin build edit, no docs/adr/**, nothing read or edited in objectstack-ai/cloud.
  • packages/spec reach: the describe text and TSDoc of the runtime tier, its pin, the liveness grade (manifest.json + regenerated state-counts/manifest.md), the liveness README's three passages about that row, and the liveness gate's self-test fixtures. Nothing else under packages/spec changed.
  • Metadata-protocol: none. No key, accepted value, metadata type or export of @objectstack/spec changes: node, sandbox and worker still parse, and an unset tier is still legal at parse time. What changes is what the local boot does with them.

Known limitations

  • The resolution boundary. Only a bare @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: under OS_HOME that normally finds nothing, so a plugin that leaves any other dependency external fails import_failed by name (os plugin build's bundled packaging bundles them). The hook is a module.register loader hook; on a runtime without it, registration is skipped and such an import fails the same way.
  • The verify-to-import window. The tree is verified, then imported. A file the plugin imports lazily, after boot, is read from disk when it is imported, after this check. Anyone who can write the install directory between the check and that read can change what runs; the directory is under the user's own OS_HOME, which this design trusts as much as the code it runs.
  • The name guard is one-way. An installed plugin never supersedes a plugin the boot registered before it (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 in serve.ts, which is not worth its blast radius in this slice.
  • node is not isolation. An installed node plugin is author code in the host process with a full PluginContext. The signature proves who built it, not what it does, and in-process code can call recordPluginArtifactVerdict itself. sandbox and worker are refused rather than run, until M3.
  • The lock is per host. Liveness is process.kill(pid, 0) on this machine. An OS_HOME shared 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.
  • The lock's wait blocks the thread (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.
  • Memory while reading an artifact is the compressed bytes plus one inflated tar, at most 256 MiB + the cap on the compressed file.
  • The verdict lives in one module instance of @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, as kernel_refused by name.
  • Uninstall removes the directory at the next boot, not at the command. A project that never boots again keeps the bytes; its record says uninstall-pending.

Verification

At head 766b96ff (base origin/main 10f27b65; origin/main is now 7614f8f0, 41 commits on, and merges without conflict. None of those commits adds a live-elsewhere row to any ledger).

Round 2, under scripts/pm/os-verify-lock.sh (slot issue-27123, NODE_OPTIONS=--max-old-space-size=4096, --maxWorkers=2), run one after another on the round's commit. Every VERDICT was command-exit 0:

  • @objectstack/core build and @objectstack/runtime build;
  • @objectstack/runtime test: 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/core test: Test Files 125/125, Tests 2825/2825;
  • @objectstack/spec plugin-runtime-tier-truthful-text: Tests 4/4;
  • cli --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);
  • cli --project integration on install.integration, serve-installed-plugins.integration and schema-migrate.one-shot-family.integration: Test Files 3/3, Tests 153/153;
  • typecheck of runtime, cli and core (each with check:test-typecheck): clean, each VERDICT command-exit 0 and check:test-typecheck OK against its ledger.

Round 1's full runs at af0b3888 (lint Tests 7570/7570; spec Tests 22358 passed, 1 todo; cli --project integration Tests 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_SHARD and no path: pnpm --filter '@objectstack/dogfood^...' build (VERDICT command-exit 0), then under the lock pnpm --filter @objectstack/dogfood test: VERDICT command-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):

  • The old lock reader (any error read as "no lock"): the directory-lock test hangs until killed (timeout 90 → exit 143).
  • The hook without the real path: the symlinked-OS_HOME test fails.
  • The hook with the old startsWith('@objectstack/') match: the climbing-specifier plugin loads.
  • The loader with the old (error as Error).message read: the two throw undefined / getter-null tests fail.

Other checks:

  • check-adr-0087-registration --base origin/main on the round's commit: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", the one verified packages/core/src/plugin-loader.ts#PluginLoader (class).
  • check-changeset-no-major --base origin/main, run with this body as the pull_request event payload so its level axis reads the PR's Clause-② and Metadata-protocol lines: no major, and LEVEL AXIS ✓ ("this PR declares clause-② no (narrowing) and metadata-protocol none, and it introduces no changeset entry graded minor or above and moves no metadata-protocol baseline — a non-protocol change at patch"), exit 0.
  • pnpm --filter @objectstack/spec check:generated: green (after --fix rebuilt the spec dist the TSDoc edit had made stale; no generated file changed).
  • eslint (--no-inline-config) over the round's touched TypeScript files: exit 0.
  • Raw control-byte scan over the round's touched files: no hit.

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

…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>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/core, @objectstack/runtime, @objectstack/spec, touching 156 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/cli/README.md, packages/runtime/src/index.ts, packages/spec/liveness/README.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 57c51aab4d2f63293676e9412daed8a5fc54c1bc.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/cli/README.md, packages/runtime/src/index.ts, packages/spec/liveness/README.md, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: environmentId (38 routes)
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 42 pages)
  • 23 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 185 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 57c51aab4d2f63293676e9412daed8a5fc54c1bc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7df566a9ac04e4bf3954330802494a1c0d38b971 — the merge of head 820d2772a6cc5fa2cd9c5af4ef52202764bbe4f9 into base 57c51aab4d2f63293676e9412daed8a5fc54c1bc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 57c51aab4d2f63293676e9412daed8a5fc54c1bc → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract-review-claim: session_01Utp6CXc5hequf76uJfzrdK · head a6a7e490e659886ff733e89e84e0c1bd17b529f0 · 2026-10-29T04:07Z

The lane seat takes the CONTRACT_REVIEW_TIER review on this head itself (Bill 2026-10-29T00:46Z: this account now serves that tier), by an isolated reviewer agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a6a7e490e659886ff733e89e84e0c1bd17b529f0
Local-runs: none
Scope: packages/spec/src/kernel/manifest.zod.ts + packages/runtime/src/installed-plugin-loader.ts, packages/cli/src/commands/plugin/install.ts, packages/cli/src/utils/installed-plugins-boot.ts, packages/cli/src/commands/serve.ts, packages/cli/src/commands/plugin/build.ts, packages/core/src/plugin-loader.ts, packages/core/src/security/plugin-artifact-verdict.ts, packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts/manifest.md, packages/spec/liveness/README.md, docs/adr/0025-plugin-package-distribution.md (§3.3, §3.5, §3.6, read on main). The rest of the diff (new CLI commands, the installations lock, the archive reader move, the verdict module's tests, README, lifecycle.mdx, the five regenerated reference pages, both changesets) is the lane seat's ACCEPT at TIER_DEFAULT; it was read to judge the one hits file, not reviewed.

Inputs: card #27123 (body and all 13 comments: triage 6463804227, ruling 6467932167 letter B′, the slice-1 claim 6469792198 and report 6480186227, the carried notes 6484915007, the slice-2 claim 6485671080, the slice-2 report 6487627427), the two slice-1 contract-review records on PR #27335 (6484891626, 6485018656, both PASS on 6b3a8286), PR #27374 body, its 37-file list, git diff origin/main...a6a7e490 at merge-base 10f27b65, and the 48 check-runs on a6a7e490 read at 2026-10-29T04:12Z. The check-runs are the gate verdicts. Completed success: Lint & Repo Gates (every Lint · job under it, check:generated and check:liveness among them), Spec property liveness, Check Changeset, Check PR Size, Governed Surface Queue Guard, PR-body guards, Type Check · source gates, Type Check · debt ledger, Test UI, Build UI Deps, Live E2E UI, Build Docs, Dogfood Regression Gate, Check Documentation Links, Auto Label, Flag docs affected by code changes. Path-skipped: Lint UI, the Test UI shards, UI Published Artifacts, Temporal Conformance, Dogfood Verify CLI, Dogfood Regression Gate shards, the two opt-in harnesses, the schedule gate. Still in_progress at this read, named so this record does not stand in for them: Test Core (1/8) through (8/8), Build Core, Type Check · workspace, Type Check · consumer gates (the TypeScript Type Check context), Type Check UI, Build & E2E UI. None failed. Test Core is where the pin test and the loader, lock and verdict suites run; this record's ④ holds on the tree, and the landing waits for those contexts to report success as the ten-context rule says.

① Derived judgments

  1. PluginRuntimeSchema and ManifestSchema.runtime: no accept-set change. The enum stays ['node', 'sandbox', 'worker'], the field stays .optional(), no key is added or tombstoned. Only the .describe() strings and TSDoc move. RIGHT, and matches Clause-②: no, Metadata-protocol: none on the PR body and both changesets. check:authorable-surface and check:api-surface ride the green Lint & Repo Gates / Type Check · source gates.
  2. The describe change is a public-surface prose change: the JSON-Schema description of runtime and of PluginRuntime changes, and the five generated reference pages carrying it (content/docs/references/kernel/{manifest,package-registry}.mdx, api/{package-api,package-api-assembled,protocol}.mdx) were regenerated; the diff of manifest.mdx and protocol.mdx carries the new sentence verbatim, and check:generated is green on the head. RIGHT: generated output follows the source, not reviewed.
  3. The pin moves with the text. plugin-runtime-tier-truthful-text.test.ts replaces the one retracted probe with three new ones (at boot for a locally installed plugin: only node is loaded, not isolated, ``sandboxandworker` are refused until`) and pins the retracted sentence absent; each regex matches both describe strings as written on the head (checked by reading: the enum describe says "refused until they are routed", the field describe "refused until ADR-0025 M3 routes them", and both contain the `until` the probe anchors on). RIGHT: the pin can red exactly when a later edit reintroduces the "NOT implemented" sentence or drops either half of the split.
  4. Liveness runtime row: live-elsewhere → live, cross-repo → in-repo, evidence installed-plugin-loader.ts#dispatchPluginRuntime + install.ts#PluginInstall. Both anchors exist on the head and both dispatch on the value (dispatchPluginRuntime(manifest.runtime) at the loader's step 5; dispatchPluginRuntime(record.runtime ?? undefined) at install, warning on a tier the boot refuses). state-counts/manifest.md moves 28/1 → 29/0. RIGHT. Derived consequence, carried to ③: the ledger's live-elsewhere population is now empty, and liveness/README.md defines that state by this very row as its "template" (L79, L302) and narrates it as elsewhere in the manifest row (L1010).
  5. Readers of manifest.runtime on the head, censused (git grep at a6a7e490, non-test): the loader's step 5, install's warning read, and the three unchanged echo lines (build.ts:128, install.ts:305, publish.ts:123). No other === 'sandbox' or === 'worker' branch exists anywhere under packages/; the QuickJS runner is still reached only from script-body factories. So the TSDoc's "is not reached from a plugin's declared tier yet" is a measured sentence, not a hope. RIGHT.
  6. Public surface outside spec, named for the row's truth, not reviewed: @objectstack/runtime exports dispatchPluginRuntime, verifyInstalledPlugins, loadInstalledPlugins, the lock and uninstall helpers and their types; @objectstack/core exports readTar/readTarGz/sha256Hex (moved, CLI re-exports) and recordPluginArtifactVerdict/pluginArtifactVerdict; four CLI commands move or appear. One behaviour tightening in core: PluginLoader.verifyPluginSignature now refuses a plugin whose signature has no host-recorded verdict, where before any well-formed string passed. Judged right against ADR-0025 §3.7 ("verify the chain at install and at load"): a self-declared signature string proved nothing, and the PR measured no plugin in the repo sets signature. It is a kernel-side consequence, not an authorable-key change, so Metadata-protocol: none stands; the core changeset discloses it in words.

② Semver level

  • .changeset/27123-runtime-tier-enforced-at-boot.md: @objectstack/spec patch, Clause-②: no — no key or accepted value changes …, Metadata-protocol: none. Matches the diff: describe text, TSDoc, pin and liveness grade only. AGENTS.md Post-Task 3: the level follows the kind alone, none ⇒ patch. RIGHT.
  • .changeset/27123-boot-time-plugin-loader.md: @objectstack/cli, @objectstack/runtime, @objectstack/core patch, both declaration lines present, the loader, the commands, the lock and the core tightening all named. Matches the diff; none ⇒ patch even for a feat, exactly as slice 1's changeset landed. RIGHT.
  • Check Changeset is green on the head; the PR body carries the same two line-initial declarations. No (narrowing) arm, so no BREAKING banner or ADR-0087 marker is owed. The card's own caveat (PluginPackagingSchema narrowing to bundled) belongs to slice 3 and does not occur on this head.
  • Clause-②: line: no is right. Nothing an author could write is refused at parse that was accepted before (node, sandbox, worker and unset all still parse); what moved is what the local boot does with the value, which is runtime behaviour the describe now states.

③ Boundary flags

Dev flags (PR body "Known limitations", report 6487627427 open_questions, the carried notes 6484915007), each answered or escalated:

  1. open_questions[0] — packages/spec/liveness/README.md L79, L302, L1010 still call manifest.runtime the live-elsewhere template. Read on the head: true. L79 defines the state with "The template is manifest.runtime, 'ENFORCED AT THE CLOUD PUBLISH GATE, NOT ENFORCED AT LOAD'"; L302 opens the live-elsewhere section with "The measured template is manifest.runtime: two CLI echo lines here …"; L1010's manifest row says "read locally only by two CLI lines that ECHO the value with no runtime === 'sandbox' branch anywhere … the ledger's first live-elsewhere row … load-side NOT implemented". After this PR the ledger has zero live-elsewhere rows and all three sentences are stale self-descriptions of the exact class card item 5 deletes. No gate reads them (check:liveness reads the JSON; green). ESCALATED as a should-fix, ruling owed to the seat, not a FAIL: the slice's "prose and grade only" limit was the seat's own order, and the README is prose under the same packages/spec/liveness/ the order admitted, so the seat can take it in this PR (three sentences: keep the row as the historical worked example, state that it flipped live on 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 say the state is currently unpopulated) or carry it to slice 3 with the other self-descriptions. Either way it should not outlive the card.
  2. ADR-0025 v2 §3.6 is now stale in the same way: "nothing dispatches on the tier at load — every plugin that loads today loads in-process" (L336), beside the already-carried §3.5 "Steps 4–7 do not exist" sentence, the §3.5 M1 ✅ line and the §7 dated note. ESCALATED: the card's Tier H docs follow-up now owes four ADR-0025 edits. Correctly absent from this PR (no docs/adr/** on the file list; queue guard green). manifest.zod.ts cites "§3.5 M1" and "§3.6", the bullets that describe what this slice built and the tier table, not the stale v2 paragraph.
  3. Carried slice-2 acceptance lines (6484915007 item 2): the kept-artifact sha against the record (artifact_changed, loader step 2) — HOLDS; a lock on the installations file (withPluginInstallationsLock, chosen over append-only, slice-1 format kept) — HOLDS as read; signature verified over the raw bytes before gunzip (install steps 2–5 precede readTarGz; the loader's step 3 precedes step 4) — HOLDS. ANSWERED.
  4. Carried should-fix (6484915007 item 3, the i18n-extract import): left out by the claim 6485671080; still open on the card for slice 3 or a sibling fix. Not a hits file. ANSWERED (carried forward).
  5. "node is not isolation." ANSWERED: the hits file says exactly that in both describes ("in process, not isolated") and the TSDoc ("author code in the host process, T0, NOT isolation"), which is what makes the new text truthful rather than a second false isolation claim.
  6. Lock per host / Atomics.wait up to 10 s / verdict lives in one core module instance / uninstall removes at the next boot. ANSWERED: all four are lane-remainder design facts, each stated in the PR body, none contradicting a claim in the hits file; the two-copies case fails closed as kernel_refused by name.
  7. No test-budget: header on packages/cli/test/serve-installed-plugins.integration.test.ts (~55 s locally). ESCALATED to the lane seat: AGENTS.md §Build & Test says a test file over 10 s of wall without a declared measured budget is its lane's card; the dev's reason (the header wants a CI reading) is sound, so the header or the card follows once Test Core reports a measured duration. Not a hits file; not a FAIL.
  8. Size: 3300 changed lines (+2955 / −345) over the 3,000 human-merge line; the PR body says so. Landing is Tier H by size (an authorized APPROVED review and the owning seat, or the maintainer's merge or enqueue) whatever this record says; this PASS clears the contract-review leg only, and the in-progress contexts in the header must also report success first.
  9. Scope conformance to the claim 6485671080: no os plugin build edit, no docs/adr/**, nothing in objectstack-ai/cloud; the spec reach is the describe text, its TSDoc, its pin, the liveness row and the regenerated counts, inside the "prose and grade only" line. HOLDS.

④ Design judgment

packages/spec/src/kernel/manifest.zod.ts — judged against ADR-0025 v2 §3.5 M1 and §3.6 (the tier table, "trust is default-deny", "M1 wires the dispatch point for T0 only; M3 routes sandbox"), ruling 6467932167 (slice 2 unchanged: the boot loader re-verifies against the store and refuses a key revoked since install), card item 5 ("replace with the M1 wording: node dispatched, others refused until M3"), and its first consumer, installed-plugin-loader.ts#dispatchPluginRuntime as os serve reaches it.

Every sentence of the new text, checked in the tree:

  • "ENFORCED at load for a plugin os plugin install installed … the boot-time loader … dispatchPluginRuntime dispatches on the tier before it imports anything." HOLDS. checkRecord runs steps 2–4 (kept sha, signature over the raw bytes, tree byte-equal to the artifact, manifest id/version) and only then dispatchPluginRuntime(manifest.runtime); verifyInstalledPlugins imports nothing; the import is in loadInstalledPlugins, for verdict.verified only. The runtime it dispatches on is read from the manifest inside the signed, re-verified artifact, not from the on-disk file, so a tier edited in the install directory is tree_changed before it is ever read as a tier. That is the right order: the dispatch cannot be steered by anything the signature does not cover.
  • "node is imported in process with a full PluginContext — author code in the host process, T0, NOT isolation." HOLDS. materializeStackPlugin(pathToFileURL(entry)) with a plain dynamic import, then kernel.use in installed-plugins-boot.ts; no enforcer scoping (that is M2, as the card says).
  • "sandbox and worker are REFUSED by name until M3 routes them, with no in-process fallback." HOLDS. dispatchPluginRuntime returns runtime_not_dispatched for anything that is not node and not unset; the loader refuses and never reaches the entry check or the import; installed-plugins-boot.ts prints the ✗ line and the banner counts it. The loader test asserts imported == [] for both tiers; the serve integration test boots a real os serve with a sandbox plugin and asserts its marker file was never written.
  • "an installed plugin that declares no tier is refused too (default-deny)." HOLDS: runtime_undeclared for undefined, null and ''. Judged against §3.6's "Trust is default-deny … node is an opt-in privilege": refusing an unspoken tier rather than defaulting it to in-process is the only reading under which the sentence is true at a local boot. Right.
  • "So a declared sandbox is never run unconfined: today it is not run at all." HOLDS, and this is the sentence that makes the describe honest where the old one was: the old text said the tier did not isolate; the new text does not claim isolation either, it says the unisolated path refuses.
  • "The QuickJS runner … is not reached from a plugin's declared tier yet." HOLDS by census (①.5).
  • "A plugin a stack's own plugins array names is the app's code and is not dispatched on this field." HOLDS: serve.ts's config plugins loop (kernel.use(pluginToLoad)) takes modules and objects, reads no manifest, and runs before registerInstalledPlugins; those entries carry no runtime value to dispatch on.
  • Both describes: "enforced at the cloud marketplace publish gate (… HTTP 422 …) and at boot for a locally installed plugin: only node is loaded (in process, not isolated); sandbox and worker are refused until … M3". Each clause is one of the sentences above; both halves of the split are still stated, as the pin requires and as the 2026-08-30 ruling that chose "say it truthfully" over retirement requires.
  • Card acceptance "the listed self-descriptions are deleted in the same PRs; no new NOT ENFORCED prose is added": HOLDS. "Load-side enforcement is NOT implemented", "nothing dispatches on the tier", "not a candidate … until it lands" are gone; the retracted sentence is pinned absent; nothing new says "NOT ENFORCED".

Break attempts, each against the live tree:

  • Reach the import without saying node. A record whose on-disk manifest says node but whose artifact says sandbox: the loader reads the artifact's manifest, so runtime_not_dispatched; and the edited file is tree_changed first. A record installed with --trust-unsigned declaring sandbox: unsigned skips step 3 only; step 5 still refuses. A plugin registered through the host's kernel.use from the config plugins array: not "a locally installed plugin" in the text's sense, and the TSDoc says so. None makes the text false.
  • Load a node plugin somewhere the dispatch is not consulted. The only production caller of loadInstalledPlugins is installed-plugins-boot.ts, reached from serve.ts on both the standalone (shouldBootWithLibrary) and host-config paths after the plugins loop (os dev / os start spawn serve); a programmatic @objectstack/runtime boot never loads installed plugins at all, so the claim "only node is loaded" is vacuously true there, never false.
  • Make dispatchPluginRuntime accept a near-miss. 'Node', 'node ', ['node'] all fall to runtime_not_dispatched (strict ===); ManifestSchema would refuse them at build anyway.
  • Text vs ADR table. §3.6's T0 row says "In-process dynamic import; full PluginContext gated by declared capabilities" — the "gated by declared capabilities" half is M2 (§3.7 "registered only today"); the hits file does not claim it (it says "full PluginContext", unqualified by grants), so it does not overclaim against the ADR.

Findings on the hits file:

  • Should-fix (small): the describe strings say "a locally installed plugin" without naming os plugin install as the install path, and the carve-out for a stack's own plugins array lives only in the TSDoc. A reader of the generated reference page (which renders the describe alone) could read "locally installed" as "any plugin on this machine". One clause ("a plugin os plugin install installed") in both describes would close it; the pin's three probes still match after that edit.
  • Should-fix (small): the field TSDoc keeps "Unset implies a pure-metadata package (no executable code)" two lines above text that now says an installed plugin with no tier is refused. Both are true (every .osplugin os plugin build produces is code-bearing, so "unset" on an installed artifact is an omission, not a metadata-only package), but the two sentences sit together without the bridge; one clause would do.
  • No must-fix. The describe tells the truth about which tier dispatches (node, in process, not isolated), which are refused (sandbox, worker, unset) and where (the boot of os serve for os plugin install-installed plugins), and the gate that keeps it true (plugin-runtime-tier-truthful-text.test.ts) moved in the same change.

Implemented-by: claude/project-thread-02c5oc
Reviewed-by: session_01Utp6CXc5hequf76uJfzrdK

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract-review-claim: session_01Utp6CXc5hequf76uJfzrdK · head af0b38885fe941d8478494385b47abbb069447ae · 2026-10-29T07:27Z

Round-1 rework head; the lane seat takes the CONTRACT_REVIEW_TIER review on this head itself (Bill 2026-10-29T00:46Z: this account now serves that tier), by an isolated reviewer agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: af0b38885fe941d8478494385b47abbb069447ae
Local-runs: none
Scope: packages/spec/src/kernel/manifest.zod.ts + packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts/manifest.md, packages/spec/liveness/README.md, packages/runtime/src/installed-plugin-loader.ts, packages/runtime/src/plugin-installation-store.ts, packages/runtime/src/package-state-store.ts, packages/runtime/src/index.ts, packages/cli/src/commands/plugin/install.ts, packages/cli/src/commands/plugin/build.ts, packages/cli/src/utils/installed-plugins-boot.ts, packages/cli/src/commands/serve.ts, packages/cli/test/serve-installed-plugins.integration.test.ts, packages/cli/test/json-stdout-purity.e2e.test.ts (on main), packages/core/src/plugin-loader.ts, packages/core/src/security/plugin-artifact-verdict.ts, packages/core/src/security/index.ts, .changeset/27123-boot-time-plugin-loader.md, .changeset/27123-runtime-tier-enforced-at-boot.md, docs/adr/0025-plugin-package-distribution.md (§3.5, §3.6, §3.7, read on main). The rest of the diff (the new CLI commands, the installations lock, the archive reader, the verdict and lock tests, README, lifecycle.mdx, the five regenerated reference pages, the check-liveness and lint-liveness test fixtures) is the lane seat's ACCEPT at TIER_DEFAULT; it was read to judge the one hits file, not reviewed.

Inputs: card #27123 (body and all 14 comments: triage 6463804227, ruling 6467932167 letter B′, the slice-1 claim 6469792198 and report 6480186227, the carried notes 6484915007, the slice-2 claim 6485671080 and report 6487627427, the cross-lane note 6489570785), PR #27374 (body, the 41-file list, git diff origin/main...af0b3888 at merge-base 10f27b65, and git diff a6a7e490 af0b3888 for the round), the earlier record 6487771335 (PASS on a6a7e490) and its four should-fixes, and the check-runs on af0b3888 read twice (07:29Z and 07:35Z). The record judges the tree, not the PR body's account of it.

Check-runs on af0b3888 at 07:35Z (their conclusions are the gate verdicts). Completed success: Lint & Repo Gates and every Lint · job under it (check:generated, check:liveness, check:adr-0087-registration ride here), Spec property liveness, Check Changeset, Check PR Size, Governed Surface Queue Guard, PR-body guards (its first run at 07:27:00Z was superseded and reads cancelled; the re-run is success), Plan Test Core shards, Type Check · source gates, Type Check · debt ledger, Test UI, Build UI Deps, Live E2E UI, Build Docs, Dogfood Regression Gate, Check Documentation Links, Flag docs affected by code changes, Auto Label. Path-skipped: Lint UI, the Test UI shards, UI Published Artifacts, Temporal Conformance, Dogfood Verify CLI, the Dogfood Regression Gate shards, the two opt-in harnesses, the schedule gate. Still in_progress at this read, named so this record does not stand in for them: Test Core (1/8) through (8/8), Build Core, Type Check · workspace, Type Check · consumer gates (together the TypeScript Type Check context), Type Check UI, Build & E2E UI. None failed. Test Core is where the pin test, the loader, lock and verdict suites and the serve integration test run; the two CI reds of a6a7e490 (Test Core 2/8 and 6/8) are fixed in this tree by fixture changes read below, and only the green shard reports will say so.

① Derived judgments

  1. PluginRuntimeSchema and ManifestSchema.runtime: no accept-set change. The enum stays ['node', 'sandbox', 'worker'], the field stays .optional(), no key added or tombstoned; only .describe() strings and TSDoc move, and the pin's fourth case still asserts all three tiers and unset parse. RIGHT, and Metadata-protocol: none is right on both changesets and the PR body.
  2. The describe change is a public-surface prose change carried into the JSON-Schema description and five generated reference pages; the regenerated manifest.mdx carries both new strings verbatim, and Lint & Repo Gates (check:generated) is green on this head. RIGHT; generated output follows the source, not reviewed.
  3. The pin moved with the text, again. SAYS_BOOT_LOADS_ONLY_NODE now reads at boot for a plugin \os plugin install` installed: only `node` is loaded; checked by concatenating the describe fragments on the head, both describes match it, SAYS_NODE_NOT_ISOLATED, SAYS_SANDBOX_WORKER_REFUSED` and the negative pin. RIGHT: the pin reds exactly when a later edit drops either half of the split or re-adds the retracted sentence.
  4. Liveness runtime row live-elsewhere → live, cross-repo → in-repo, evidence installed-plugin-loader.ts#dispatchPluginRuntime + install.ts#PluginInstall. Both anchors exist and dispatch on the value (loader step 5 at checkRecord; install.ts:435). state-counts/manifest.md 28/1 → 29/0. RIGHT. The live-elsewhere population is now zero, and the ledger README's three passages (L79, L302, L1010 on main) are rewritten in this tree to say so, keeping the row as the worked example; check:liveness and Spec property liveness are green.
  5. Readers of manifest.runtime on the head, censused (non-test, non-spec): the loader's step 5, install.ts:397 (recorded onto the installation record) and :435 (the install-time warning read), and the three unchanged echo lines (build.ts:128, install.ts:319, publish.ts:123). No === 'sandbox' or === 'worker' branch exists anywhere else under packages/. So the TSDoc's "not reached from a plugin's declared tier yet" is measured. RIGHT.
  6. Public surface outside spec, named for the row's truth and for ②, not reviewed. Widenings: @objectstack/runtime exports dispatchPluginRuntime, verifyInstalledPlugins, loadInstalledPlugins and their types, the lock (withPluginInstallationsLock, PluginInstallationsLockedError, three constants), markPluginUninstall, dropPluginInstallation, installedPluginDisabledIds, setInstalledPluginDisabled, the two kept-file names, and loadDisabledPackageIdsStrict; @objectstack/core exports readTar, readTarGz, sha256Hex, MAX_UNPACKED_ARTIFACT_BYTES, ArchiveFile (moved from the CLI, which re-exports), and recordPluginArtifactVerdict / pluginArtifactVerdict / PluginArtifactVerdict; three CLI commands appear. One narrowing: PluginLoader.verifyPluginSignature (packages/core/src/plugin-loader.ts:513) now throws for a plugin whose signature has no verdict recorded against that object, or a different one, where before any well-formed ed25519:KEYID:B64 string passed. signature is a field of core's exported PluginMetadata extends Plugin (plugin-loader.ts:56, "Plugin signature for security verification"), reached by every kernel.use (kernel.ts:354 → loadPlugin → the check at :186 whenever the field is set, with no option gating it), and toPluginMetadata casts rather than copies, so the WeakMap lookup is keyed on the object the host passed. The narrowing is the right behaviour under ADR-0025 §3.7 ("verify the chain at install and at load") and the PR measured no plugin in this repo sets the field. But it is a narrowing of a published public surface of a released package, and the declaration on it is WRONG; carried to ②.

② Semver level

  • .changeset/27123-runtime-tier-enforced-at-boot.md (@objectstack/spec patch, Clause-②: no, Metadata-protocol: none): matches the diff, describe text and TSDoc only; none ⇒ patch (AGENTS.md Post-Task 3). RIGHT.
  • .changeset/27123-boot-time-plugin-loader.md (@objectstack/cli, @objectstack/runtime, @objectstack/core patch, Metadata-protocol: none, a Migration paragraph FROM plugin.signature + kernel.use TO recordPluginArtifactVerdict(...) or an unset field): the level is right (none ⇒ patch; AGENTS.md: "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition"). The Clause-②: line is wrong. It reads no — no key, accepted value or export of \@objectstack/spec` changes. Clause ② is not confined to @objectstack/spec: the fleet's one definition (scripts/check-changeset-no-major.mjs L914–L922) says the line keeps its meaning "does this diff widen or narrow a published accept set or public surface", gives "a driver, a posture" as non-protocol narrowings that carry the arm, and the ADR-0087 gate's docblock (L656–L676) names the hole the arm closes: an accept-set narrowing shipped with no banner (#16296, #14238) is "exactly the change a consumer needs told about". The contract-review faces say the same (scripts/pm/record-recognisers.mjsL140–L148 and L163–L166, rulings 6053791774, 6203713500 and 6442046166): "aClause-②: nocard that NARROWS a published accept set owes one isolated review", and "a narrowing of a published accept set in runtime code alone is judged against the spec onmain". kernel.use's accept set for a Plugincarryingsignatureis such a surface, and this diff narrows it for every out-of-repo host, which the changeset's own Migration paragraph concedes. The dev's weighing in Acceptance notes ("Clause ② declares a change to an accept set an author writes: a spec key, an accepted value, a metadata type") is theMetadata-protocol:` line's definition, not Clause ②'s.
  • Must-fix (FAIL): on the PR body and on .changeset/27123-boot-time-plugin-loader.md, the line becomes Clause-②: no (narrowing) at the start of the line; the changeset body gains the BREAKING banner (**BREAKING** …, signal 2 of the ADR-0087 gate, which signal 4 now also carries) and exactly one disposition marker in the adr-0087: comment form the gate prints on failure (not-required (CATEGORY) why; there is no metadata to convert, and runtime-interface-only is the category in stock for a core interface change, but the gate's own output is the authority); the Migration paragraph already present satisfies "breaking changesets must carry their migration". The level stays patch with Metadata-protocol: none; Check Changeset is the authority if it grades the arm otherwise. Consequences the seat must carry: the declared narrowing puts the PR on the narrowing face, so the record on the next head is owed at CONTRACT_REVIEW_TIER with the narrowing's scope floor ("every path of the diff outside the prose faces"), and the dev report's dogfood_battery: n/a — no (narrowing) arm line no longer holds. The narrowing itself is pre-judged under ④ below so that review is short.
  • Check Changeset green on this head is not evidence against this: the gate's own text says a narrowing outside the authorable-surface shards "is invisible to them, so the declaration — not the diff — is still the carrier".
  • The card's own caveat (PluginPackagingSchema narrowing to bundled) belongs to slice 3 and does not occur on this head.

③ Boundary flags

The four should-fixes of record 6487771335, each against the tree:

  1. Describes say "a locally installed plugin" without naming the install path. RESOLVED: both describes now read "at boot for a plugin os plugin install installed"; the pin probe moved with them (①.3).
  2. Field TSDoc keeps "Unset implies a pure-metadata package" beside "an installed plugin with no tier is refused" with no bridge. RESOLVED: the TSDoc now says an artifact os plugin install installs carries code, so on one an unset tier is an omission, refused runtime_undeclared. One residue, should-fix (small), under ④.
  3. packages/spec/liveness/README.md L79, L302, L1010. RESOLVED in this tree: the state table row, the section opener and the manifest row each say the row flipped to live on 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 that no row holds live-elsewhere today, keeping the account as the worked example. This also closes report 6487627427's one open_questions entry (edit in this PR, or follow-up): edited here.
  4. No test-budget: header on packages/cli/test/serve-installed-plugins.integration.test.ts. STILL OPEN. The dev's reason stands as read: the merged Test Core log lists only the slowest 20 files and the per-shard capture is not reachable from the seat; the file also grew this round (a build and a sixth install; 30.1 s of tests, 57 s with transform locally per the PR body). AGENTS.md §Build & Test: a file over 10 s of wall without a declared measured budget is its lane's card. ESCALATED to the lane seat, as before: once Test Core on this head reports, read this file's duration from the test-core-timings-* capture and add the header, or file the card. Not a hits file; not a FAIL.

Dev flags on this head (PR body "Known limitations", Acceptance notes), each answered or escalated:

  1. Clause-② on the core narrowing. Answered under ②: must-fix.
  2. The resolution boundary (only bare @objectstack/* from inside a verified install directory is redirected; a runtime without module.register skips the hook and the import fails by name). ANSWERED, lane-remainder design fact; it contradicts nothing in the hits file, whose text says "imported in process", not how dependencies resolve.
  3. The verify-to-import window (a lazily imported file is read after the check). ANSWERED: the hits file says "dispatches on the tier before it imports anything", which is what verifyInstalledPlugins (imports nothing) then loadInstalledPlugins does; it does not claim the tree is sealed after import, and the PR states the trust boundary (OS_HOME is the user's).
  4. The name guard is one-way. ANSWERED: plugin_name_taken refuses the installed plugin when the host already holds the name; a later host registration supersedes with the kernel's warning. The host wins in both directions; nothing in the hits file speaks to it.
  5. node is not isolation. ANSWERED: the hits file says so in both describes ("in process, not isolated") and the TSDoc ("author code in the host process, T0, NOT isolation").
  6. Lock per host, Atomics.wait up to 10 s, memory bound, the verdict in one core module instance, uninstall at the next boot. ANSWERED: lane-remainder facts, each stated in the PR body; the two-copies-of-core case fails closed as kernel_refused by name.
  7. Size. 4206 changed lines (+3828/−378, 41 files) over the 3,000 line: the landing is Tier H by size whatever this record says (an authorized APPROVED review and the owning seat, or the maintainer's merge or enqueue).
  8. Scope conformance to claim 6485671080: no os plugin build edit, no docs/adr/** (queue guard green), nothing in objectstack-ai/cloud; the spec reach is the describe text, TSDoc, pin, liveness row, counts, README and the liveness gate's test fixtures, the last two taken under the seat's 04:21Z authorization named in the PR body. HOLDS. The off-surface files are each named with a reason in the PR body.

Carried from the card:

  1. 6484915007 item 2 (slice-2 acceptance lines): kept-artifact sha against the record (artifact_changed, loader step 2) HOLDS; a lock on the installations file (withPluginInstallationsLock, re-entrant, every read-modify-write, the steal-lock clear) HOLDS as read; signature over the raw bytes before gunzip (install steps 2–5 precede readTarGz at install.ts:277; loader step 3 precedes step 4) HOLDS. ANSWERED.
  2. 6484915007 item 3 (the i18n-extract import for five deployment-level objects): still out of this slice by the claim; open on the card. ANSWERED (carried).
  3. 6484915007 item 1 and record 6487771335 ③.2: the card's Tier H docs follow-up owes four ADR-0025 edits (§3.5 M1 ✅ line, §7 dated note, the "Steps 4–7 do not exist" sentence, and §3.6's "nothing dispatches on the tier at load", all still on main as read). Correctly absent from this PR.
  4. Cross-lane note 6489570785 (nightly json-stdout-purity family pin red on main after slice 1; slice 2 named the natural carrier). Not carried: packages/cli/test/json-stdout-purity.e2e.test.ts is not on the file list, so nightly-tiers: red on main #27114 stays with the devx holder. Two facts this tree changes about that red, ESCALATED to the lane seat and the devx holder so the fix is written against this head, not main: (a) the three new commands (disable, enable, uninstall) declare json: Flags.boolean but contain no bootSchemaStack( text, so the discovery does not pick them up and the red does not widen; (b) plugin install LEAVES the discovery on this head, because its only bootSchemaStack( call moved into packages/cli/src/utils/plugin-trust-read.ts, which the discovery (a text probe over src/commands/**) cannot see, while the command still boots the stack through that util. So after this PR the nightly's discovered set is 23 (plugin trust list alone undriven), and os plugin install --json's stdout purity is no longer pinned by that file at all. The fix on nightly-tiers: red on main #27114 should list plugin install by hand or teach the discovery the util (the [finding] nightly-only e2e pins read their own src/ RAW, so a DOCBLOCK can break a pin whose code never moved — and no pull request can ever redden them, because the tier only runs on main #18520 masking precedent runs the other way). Should-fix, not a hits file, not a FAIL.
  5. Report 6487627427 "size: OVER → Tier H" and the PR body's Tier H line agree with ③.11.

④ Design judgment

packages/spec/src/kernel/manifest.zod.ts — judged against ADR-0025 v2 §3.5 M1 ("a tier dispatch point that M1 wires for node only", "T0 is author code in the host process"), §3.6 ("Trust is default-deny … node is an opt-in privilege"; "M1 wires the dispatch point for T0 only; M3 routes runtime: 'sandbox'"), ruling 6467932167 (slice 2 unchanged: the boot loader re-verifies against the store and refuses a key revoked since install), card item 5 (replace the self-description with the M1 wording), and its first consumer installed-plugin-loader.ts#dispatchPluginRuntime as os serve reaches it (serve.ts:4743 → registerInstalledPlugins → loadInstalledPlugins → verifyInstalledPlugins → checkRecord step 5).

Every sentence of the text on this head, checked in the tree:

  • "ENFORCED at load for a plugin os plugin install installed … dispatchPluginRuntime dispatches on the tier before it imports anything." HOLDS. checkRecord runs steps 2–4 (kept sha, signature over the raw bytes against the resolved key with requirePlatform: false, tree byte-equal with no extra file or non-regular entry, manifest id/version) and only then dispatchPluginRuntime(manifest.runtime) at L386; the runtime dispatched on is the manifest INSIDE the re-verified artifact, so a tier edited on disk is tree_changed before it is read as a tier; verifyInstalledPlugins imports nothing, and the import is in loadInstalledPlugins for verdict.verified only.
  • "node is imported in process with a full PluginContext — author code in the host process, T0, NOT isolation." HOLDS: materializeStackPlugin(pathToFileURL(entry)) with a plain dynamic import, then kernel.use in installed-plugins-boot.ts, no enforcer scoping (M2 by the ADR's own phasing, and the text does not claim the §3.6 table's "gated by declared capabilities" half).
  • "sandbox and worker are REFUSED by name until M3 routes them, with no in-process fallback" / describes: "refused until they are routed (ADR-0025 M3), never run in process instead". HOLDS: strict === on 'node', everything else not unset is runtime_not_dispatched; the loader refuses before the entry check and the import; the serve integration test boots a real os serve with a sandbox plugin and asserts the runtime_not_dispatched line and that its marker file was never written.
  • "an installed plugin that declares no tier is refused too (default-deny)" / TSDoc "(runtime_undeclared, default-deny)". HOLDS for undefined, null and ''; code name matches. Right against §3.6: refusing an unspoken tier rather than defaulting to in-process is the only reading under which "default-deny" is true at a local boot.
  • "So a declared sandbox is never run unconfined: today it is not run at all." HOLDS, and it is the sentence that keeps the describe honest where the old one was not.
  • "The QuickJS runner … is not reached from a plugin's declared tier yet." HOLDS by census (①.5).
  • "A plugin a stack's own plugins array names is the app's code and is not dispatched on this field." HOLDS: serve.ts's config loop (:4692–:4731) takes modules and objects, reads no manifest, and runs before registerInstalledPlugins.
  • Field TSDoc: "An artifact os plugin install installs carries code, so on one an unset tier is an omission". HOLDS for every artifact os plugin build produces (build.ts:134 refuses a missing entry and always bundles dist/index.mjs). See the residue below.
  • Both describes still state BOTH halves of the split (publish gate + HTTP 422; boot), as the 2026-08-30 ruling and the pin require; the retracted sentence is absent from the file (scanned: no "NOT ENFORCED", "not implemented", "nothing dispatches" remains). Card acceptance "no new NOT ENFORCED prose is added": HOLDS.
  • os serve reaches the dispatch on both boot shapes: the standalone (shouldBootWithLibrary) and host-config paths both fall through to :4743 (no early return between :2866 and :4743 at function level), and os dev / os start spawn serve. A programmatic @objectstack/runtime boot never loads installed plugins, so "only node is loaded" is never false there.

Break attempts, each against the live tree:

  • Reach the import without saying node. On-disk manifest node, artifact manifest sandbox: the loader reads the artifact's manifest, and the edited file is tree_changed first. A --trust-unsigned record declaring sandbox: unsigned skips step 3 only; step 5 refuses. 'Node', 'node ', ['node']: runtime_not_dispatched (strict ===; the schema refuses them at build anyway). An installed id colliding with a host plugin: plugin_name_taken before kernel.use. None makes the text false.
  • Make the install side contradict the describe. os plugin install accepts a sandbox artifact with a warning and --json boot: { loads: false, error } (install.ts:435); the describe claims enforcement "at boot", not at install, so no contradiction. The install-time read is the second evidence anchor of the liveness row and does dispatch on the value.
  • The tree that is not the artifact. walkTree reports any non-regular entry (a symlink) as odd → tree_changed; a file the artifact lacks → tree_changed; safeEntryPath refuses absolute, .., backslash and drive-letter entries → artifact_unreadable. The entry must be a file of the artifact (entry_missing).
  • Text vs the §3.6 table. The table's T0 row says "full PluginContext gated by declared capabilities"; the half after "gated" is M2 (§3.7 v2 note) and the hits file does not claim it. The table's T1 row says "sandbox exists"; the hits file says it is not reached from the tier, which is the measured truth (§3.6 v2 says the same). No overclaim.

Findings on the hits file:

  • Should-fix (small): the field TSDoc's premise "An artifact os plugin install installs carries code" is os plugin build's guarantee, not install's: install.ts checks the manifest entry, the archive entries and integrity (skipped when the map is absent), but not that an entry module exists, so a hand-rolled .osplugin with no dist/index.mjs installs and is refused at boot (runtime_undeclared if it names no tier, else entry_missing). The refusal the sentence promises therefore holds in every case; only the premise is one tool wider than the code. One clause ("an artifact os plugin build produces carries code; the loader refuses one with no entry either way") would close it. Not a must-fix: no sentence is false about what the boot does.
  • No must-fix on the hits file. The describe tells the truth about which tier dispatches (node, in process, not isolated), which are refused (sandbox, worker, unset), where (the boot of os serve for os plugin install-installed plugins), and the gate that keeps it true (plugin-runtime-tier-truthful-text.test.ts) moved with the text in the same change.

The narrowing, pre-judged on the narrowing face (ruling 6442046166's question), so the next record is short. Does PluginLoader.verifyPluginSignature's new refusal take a declared capability away at the consumer, or make the spec's own refusal real? It makes ADR-0025 §3.7's refusal real: "verify the chain at install (§3.5 step 4) and at load (§3.5 step 7)". Before this head the load-side half accepted any well-formed string, which let a module's own signature read as verified; now the only path to a passing signed plugin is the host that verified the bytes recording its verdict (the loader does, at installed-plugin-loader.ts:617). A malformed signature is still refused first; an unsigned plugin is untouched; signature_conflict refuses a module whose own field disagrees with the verified one. The one capability taken away at the consumer is "set plugin.signature and have the kernel log it as well-formed", which was never a verification and which the Migration paragraph prescribes a replacement for. The narrowing is right in substance; its declaration is what ② fails.

Implemented-by: claude/project-thread-02c5oc
Reviewed-by: session_01Utp6CXc5hequf76uJfzrdK

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract-review-claim: session_01Utp6CXc5hequf76uJfzrdK · head 766b96ff253ec0cbabb4b707fc4ed2edf3141c7b · 2026-10-29T09:27Z

Round-2 head (declares Clause-②: no (narrowing)); the lane seat takes the CONTRACT_REVIEW_TIER review on this head itself (Bill 2026-10-29T00:46Z: this account now serves that tier), by an isolated reviewer agent.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 766b96ff253ec0cbabb4b707fc4ed2edf3141c7b
Local-runs: none
Scope: packages/spec/src/kernel/manifest.zod.ts + packages/spec/src/kernel/plugin-runtime-tier-truthful-text.test.ts, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts/manifest.md, packages/spec/liveness/README.md, packages/spec/scripts/liveness/check-liveness.test.ts, packages/lint/src/lint-liveness-properties.test.ts, packages/lint/src/lint-liveness-properties.ts (on main, unchanged), content/docs/references/{kernel/manifest,kernel/package-registry,api/package-api,api/package-api-assembled,api/protocol}.mdx, packages/runtime/src/installed-plugin-loader.ts, packages/runtime/src/plugin-installation-store.ts, packages/runtime/src/package-state-store.ts, packages/runtime/src/index.ts, packages/cli/src/commands/plugin/install.ts, packages/cli/src/commands/plugin/publish.ts (on main and on the head), packages/cli/src/commands/plugin/build.ts (on the head, unchanged), packages/cli/src/commands/plugin/{disable,enable,uninstall}.ts, packages/cli/src/commands/plugin/trust/revoke.ts, packages/cli/src/commands/serve.ts, packages/cli/src/utils/{installed-plugins-boot,plugin-install,plugin-trust-read,osplugin,format}.ts, packages/cli/test/serve-installed-plugins.integration.test.ts, packages/cli/README.md, content/docs/plugins/index.mdx, content/docs/protocol/kernel/lifecycle.mdx, packages/core/src/plugin-loader.ts, packages/core/src/kernel.ts and lite-kernel.ts (on the head, unchanged), packages/core/src/security/{plugin-artifact-verdict,plugin-artifact-archive,plugin-artifact-integrity,index}.ts, packages/core/src/security/plugin-artifact-verdict.test.ts, .changeset/27123-boot-time-plugin-loader.md, .changeset/27123-runtime-tier-enforced-at-boot.md, docs/adr/0025-plugin-package-distribution.md (§3.5, §3.6, §3.7, read on main), scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs and scripts/measure-test-shard-timings.mjs (the gates' own grammars, read not run). This head serves two faces at once: the schema face (hits = manifest.zod.ts, as record-recognisers --paths prints on the 44-file list) and the narrowing face (Clause-②: no (narrowing) at the start of a line on the PR body and on the core changeset), whose floor is every path of the diff outside the prose faces; every non-test path of the diff was read for the second question, and the test files for what they pin.

Inputs: card #27123 (body and all 15 comments: triage 6463804227, ruling 6467932167 letter B′, the slice-1 claim 6469792198 and report 6480186227, the carried notes 6484915007, the slice-2 claim 6485671080 and report 6487627427, the cross-lane note 6489570785); PR #27374 (body, the 44-file list, git diff origin/main...766b96ff at merge-base 10f27b65, and git diff af0b3888 766b96ff for the round, 15 files +548/−71); the two earlier records, PASS 6487771335 on a6a7e490 and FAIL 6490914228 on af0b3888, read for their open items and judged against the tree, not adopted; the dev's round-2 report at /mnt/project-files/issue-27123/os-dev-report-s2-r2.json (open_questions: [], three deviations); rulings 6053791774, 6203713500, 6442046166 for the narrowing face's scope and its ④; the check-runs on 766b96ff read three times (09:29Z, 09:35Z, 09:37Z). The record judges the tree.

Check-runs on 766b96ff at 09:37Z (their conclusions are the gate verdicts). Completed success: Lint & Repo Gates and every Lint · job under it (check:generated, check:liveness, check:adr-0087-registration and the test-budget reader ride here), Spec property liveness, Check Changeset (both runs), Check PR Size, Governed Surface Queue Guard, PR-body guards (both runs), Plan Test Core shards, Build Core, Build Docs, Build UI Deps, Build & E2E UI, Live E2E UI, Test UI, Type Check · source gates, Type Check · debt ledger, Type Check · consumer gates, Dogfood Regression Gate, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter. Path-skipped: Lint UI, the Test UI shards, UI Published Artifacts, Temporal Conformance, Dogfood Verify CLI, the Dogfood Regression Gate shards, the two opt-in harnesses, the schedule gate. Still in_progress at this read, named so this record does not stand in for them: Test Core (1/8) through (8/8), Type Check · workspace (half of the TypeScript Type Check context; its other half, consumer gates, is green) and Type Check UI. None failed. Test Core is where the spec pin, the loader, lock and verdict suites, the two liveness fixture suites and the budgeted serve integration test run; the landing waits for those contexts to report success, as the ten-context rule says.

① Derived judgments

  1. PluginRuntimeSchema and ManifestSchema.runtime: no accept-set change. The enum stays ['node', 'sandbox', 'worker'], the field stays .optional(), no key is added or tombstoned; only .describe() strings and TSDoc move (the round's one spec hunk is four TSDoc lines). The pin's fourth case still asserts all three tiers and unset parse and 'isolated' is refused. RIGHT, and Metadata-protocol: none is right on both changesets and the PR body.
  2. The describe change propagates to the JSON-Schema description and five generated reference pages, each of which carries the new sentence verbatim; Lint & Repo Gates (check:generated) is green on this head. RIGHT; generated output follows the source, not reviewed.
  3. The pin matches the text as written on this head. Checked by reading the five probes against both describe strings: SAYS_BOOT_LOADS_ONLY_NODE (at boot for a plugin \os plugin install` installed: only `node` is loaded), SAYS_NODE_NOT_ISOLATED, SAYS_SANDBOX_WORKER_REFUSED (`sandbox` and `worker` are refused until), the publish-gate and 422 probes all match the enum describe ("refused until they are routed") and the field describe ("refused until ADR-0025 M3 routes them"); RETRACTED_NOT_ENFORCED` matches nothing in the file. The round changed neither the pin nor a describe string. RIGHT.
  4. Liveness runtime row live-elsewhere → live, cross-repo → in-repo, evidence anchors installed-plugin-loader.ts#dispatchPluginRuntime (L180, reached from checkRecord L392) and install.ts#PluginInstall (L435) both exist and dispatch on the value; counts 28/1 → 29/0; the README's three passages say the row flipped on 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 that no row holds the status today; Spec property liveness and check:liveness are green. RIGHT. The two liveness test files change fixtures only (planted rows, union of statuses); no gate source under scripts/liveness/ or packages/lint/src/*.ts is on the file list.
  5. Readers of manifest.runtime on this head, censused (non-test, non-spec): the loader's step 5 (L392), install.ts:397 (recorded) and :435 (the install-time warning), and the three echo lines (build.ts:128, install.ts:319, publish.ts:123); the only === 'node' / === 'sandbox' branch under packages/ is dispatchPluginRuntime itself. The TSDoc's "not reached from a plugin's declared tier yet" is a measured sentence on this tree. RIGHT.
  6. The declared narrowing, read in core. PluginLoader.verifyPluginSignature (plugin-loader.ts:519) now refuses a plugin whose signature has no verdict recorded against that object, or a different one, where any well-formed ed25519:KEYID:B64 passed. toPluginMetadata casts rather than copies (L398), so the WeakMap is keyed on the object the host passed; the check is reached from ObjectKernel.use (kernel.ts:348 → loadPlugin → L192, whenever the field is set, no option gating it). LiteKernel.use (lite-kernel.ts:70) runs assertPluginContract only and never ran a signature check, so it is unaffected; the PR body's "every kernel.use" is ObjectKernel's, and the Migration paragraph over-warns a LiteKernel host by one kernel — harmless, no fix owed. No plugin object in the repo sets signature: outside tests (census on the head); skills/** and content/** teach no hand-set ed25519: value after this round. RIGHT in substance; declaration judged under ②.
  7. A second narrowing the declaration does not name, found on the narrowing face's floor: os plugin publish shares the moved reader. On main, publish.ts:110 called the CLI's own readTarGz (unbounded gunzipSync; every tar entry read as a file whatever its typeflag; prefix ignored). On this head it calls @objectstack/core's through the osplugin.ts re-export: the inflate is capped at MAX_UNPACKED_ARTIFACT_BYTES (256 MiB), a link, device, pax or GNU-extension entry, a cut-short entry and an unreadable size each throw by name, and publish reports the throw as "Cannot read manifest from artifact", exit 1. For an artifact os plugin build produces nothing changes (createTar writes regular files only, refuses a path over 100 bytes at L90, writes no prefix). For a hand-rolled artifact the publish preflight's accept set narrows in two cases: one that inflates past 256 MiB (accepted before, refused now, with or without an integrity map) and one with a non-regular tar entry on an artifact that declares no integrity map (absent map is permissive by contract, so it was accepted; with a map, extra_file already refused it). The POSIX prefix join is the other direction (a foreign ustar writer's long path now reads whole) and is not a narrowing. The core changeset names every one of the reader's new refusals but places them under the os plugin install bullet and says only "re-exported where the CLI read it"; publish is not named on the changeset, the PR body or the Clause-②: line. The arm, banner and disposition already cover the PR, so this is a disclosure gap, not a wrong level; carried to ② as a should-fix.
  8. Widenings outside spec, named for the row's truth, not reviewed: @objectstack/runtime exports the loader (dispatchPluginRuntime, verifyInstalledPlugins, loadInstalledPlugins and eight types), the lock (withPluginInstallationsLock, PluginInstallationsLockedError, two constants), markPluginUninstall, dropPluginInstallation, installedPluginDisabledIds, setInstalledPluginDisabled, the two kept-file names and loadDisabledPackageIdsStrict; @objectstack/core exports readTar, readTarGz, sha256Hex, MAX_UNPACKED_ARTIFACT_BYTES, ArchiveFile, recordPluginArtifactVerdict, pluginArtifactVerdict, PluginArtifactVerdict; three CLI commands appear. INSTALLATIONS_LOCK_STEAL_STALE_MS left the runtime index this round: it was added in round 1 of this unreleased PR, so no published surface moves. os plugin install's environment-id derivation is unchanged (OS_ENVIRONMENT_ID else the default, now through pluginEnvironmentId()); its refusal codes are unchanged, only their order (signature before archive). No other existing command's behaviour changed.

② Semver level

  • .changeset/27123-runtime-tier-enforced-at-boot.md (@objectstack/spec patch, Clause-②: no, Metadata-protocol: none): matches the diff, describe text, TSDoc, pin and liveness grade only; the round's one edit to it follows the TSDoc premise (os plugin build). none ⇒ patch (AGENTS.md Post-Task 3). RIGHT.
  • .changeset/27123-boot-time-plugin-loader.md (@objectstack/cli, @objectstack/runtime, @objectstack/core patch): the FAIL record's must-fix is resolved in the tree. Clause-②: no (narrowing) stands at the start of the line on the changeset and on the PR body; the body carries **BREAKING** accept-set narrowing at \kernel.use` (`@objectstack/core`); exactly one marker in the gate's comment form, adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader) why, whose claim resolves (an exported classat head) and whose declaration site is neither a*.zod.ts, a contracts/ file nor an object definition, the two predicates the gate's §"fifth category" verifies; the Migration paragraph carries FROM (plugin.signature+kernel.use) → TO (recordPluginArtifactVerdict(…)beforekernel.use, or unset). The level stays patchbesideMetadata-protocol: none: AGENTS.md Post-Task 3 ("a non-protocol change, widening or breaking, ships patchand keeps its BREAKING banner and ADR-0087 disposition") and thecheck-changeset-no-majordocblock (a narrowing "a driver, a posture" shipspatch; only narroweddemands the arm,none` beside it is a non-protocol narrowing) agree. RIGHT.
  • Check Changeset is success on this head (both runs) and check:adr-0087-registration rides the green Lint & Repo Gates: the gates agree with the declaration.
  • Should-fix (small, disclosure): the core changeset's reader sentence, and the PR body's "What changes" entry for the archive reader, name os plugin publish beside install as the second command whose preflight now refuses an artifact that inflates past 256 MiB or carries a non-regular tar entry (①.7). One clause; the arm, the banner, the disposition and the level do not move.
  • The card's own caveat (PluginPackagingSchema narrowing to bundled) belongs to slice 3 and does not occur on this head.

③ Boundary flags

The FAIL record 6490914228, each item against the tree:

  1. ② must-fix, the Clause-② declaration of the core narrowing. RESOLVED (② above). Its consequences are carried: the PR is on the narrowing face and this record serves it; the dev report's dogfood_battery line now reads a full run on this head (Test Files 348 passed, 3 skipped; Tests 2806 passed, 20 skipped), and Dogfood Regression Gate is green.
  2. Should-fix, the field TSDoc's premise named os plugin install where the guarantee is os plugin build's. RESOLVED: the TSDoc now reads "An artifact os plugin build produces carries code … the boot-time loader refuses an installed plugin with no tier either way (runtime_undeclared, default-deny), and one with no entry module too." Checked: build.ts:134–141 refuses a missing entry and always bundles dist/index.mjs (L196, L211); install.ts reads only the manifest entry (L296–298) and checks no entry module, so a hand-rolled artifact without one installs; the loader refuses it at entry_missing (L397) after the tier check (L392), so "either way … no entry module too" is exactly the code's order. The pin is unchanged; its run is a Test Core matter still in progress, and by reading its probes match.
  3. ③.4, the test-budget: header. RESOLVED: packages/cli/test/serve-installed-plugins.integration.test.ts line 2 (inside the header) reads // test-budget: 40 — spawns the real producer once per single-cause fixture: …. Against measure-test-shard-timings.mjs's grammar: the em dash and colon separators are right, 40 is a whole number on the 10 s grid above the 10 s line, the class is TEST_BUDGET_CLASSES[0] verbatim, the reason is non-empty, and 27.08 s × CEILING_HEADROOM 1.25 = 33.85 rounds up to 40. The cited reading (Test Core 5/8 on af0b3888, job 123319866423) is the seat's own and is not readable from here; the round added only this line to the file, so the reading is of this file's tests. Lint & Repo Gates, where the reader runs, is green. RIGHT.
  4. ③.16, nightly-tiers: red on main #27114 json-stdout-purity on main. Unchanged on this head: packages/cli/test/json-stdout-purity.e2e.test.ts is not on the file list, plugin-trust-read.ts:39 still holds plugin install's only bootSchemaStack( call, so the discovery blind spot the FAIL record described stands. The devx holder's; not a FAIL here.

Dev flags on this head (PR body "Known limitations", Acceptance notes, the report's three deviations), each answered or escalated:

  1. The resolution boundary, now with real-path roots and the package-name guard. ANSWERED, lane-remainder design fact; the round's namesHostPackage (no empty, . or .. segment, no % or backslash) and realPathOr change nothing the hits file claims ("imported in process", not how dependencies resolve).
  2. The verify-to-import window; the name guard one-way; node is not isolation; the lock per host; Atomics.wait up to 10 s; memory bound; the verdict in one core instance; uninstall at the next boot. ANSWERED as in the earlier records: each stated in the PR body, none contradicting the hits file, whose text says "in process, not isolated" and "dispatches on the tier before it imports anything"; the two-copies-of-core case fails closed as kernel_refused by name.
  3. Deviation: content/docs/plugins/index.mdx off-surface. ANSWERED: it is the Skills-docs: carrier for the narrowing (the one page that taught signature: 'ed25519:…' by hand now shows recordPluginArtifactVerdict before kernel.use), and nothing under skills/** teaches the field (census).
  4. Deviation: the steal-lock constant removed; the stale-lock rule now judges the steal lock by pid and age. ANSWERED: unreleased surface (①.8); the round's readLockFile refuses on anything but ENOENT, naming the path, and the release leaves a lock it can no longer read (finally guarded), both as the body says. Read, not reviewed.
  5. Size. 4711 changed lines (44 files, +4319/−392) over the 3,000 line: the landing is Tier H by size whatever this record says (an authorized APPROVED review and the owning seat, or the maintainer's merge or enqueue). The PR body says so.
  6. Scope conformance to claim 6485671080: no os plugin build edit (build.ts is not on the list), no docs/adr/** (queue guard green), nothing in objectstack-ai/cloud; the spec reach is the describe text, TSDoc, pin, liveness row, counts, README and the liveness gate's test fixtures. HOLDS.

Carried from the card:

  1. 6484915007 item 2 (slice-2 acceptance lines): kept-artifact sha against the record (artifact_changed, readKept) HOLDS; a lock on the installations file (withPluginInstallationsLock, every read-modify-write, re-entrant per thread) HOLDS as read; signature over the raw bytes before gunzip (install steps 2–5 precede readTarGz at install.ts:277; the loader's step 3 precedes readTarGz at L353) HOLDS. ANSWERED.
  2. 6484915007 item 3 (the i18n-extract import for five deployment-level objects): still out of this slice by the claim; open on the card. ANSWERED (carried).
  3. The card's Tier H docs follow-up owes four ADR-0025 edits (§3.5 M1 ✅ line, §7 dated note, §3.5 "Steps 4–7 do not exist … there is no os plugin install", §3.6 "nothing dispatches on the tier at load"), all four still on main as read today. Correctly absent from this PR.
  4. New, should-fix, carried (not a hits file, not on the file list): packages/lint/src/lint-liveness-properties.ts:217 (a comment) and :295 (the live-elsewhere branch's defaultHint, an author-facing string) still name manifest.runtime as the example of a key "enforced in a sibling repo, not here". The branch fires for no row today, so no author sees it on this head; the next live-elsewhere row would be told that manifest.runtime, now live, is its worked example. It is a self-description of the class card item 5 deletes, one clause to fix, in slice 3 or beside the docs follow-up. (packages/lint/CHANGELOG.md:6166 says the same and is release-owned: never edited.)

④ Design judgment

packages/spec/src/kernel/manifest.zod.ts (the schema face) — judged against ADR-0025 v2 §3.5 M1 ("a tier dispatch point that M1 wires for node only", "T0 is author code in the host process"), §3.6 ("Trust is default-deny … node is an opt-in privilege"; "M1 wires the dispatch point for T0 only; M3 routes runtime: 'sandbox'"), ruling 6467932167 (slice 2 unchanged: the boot loader re-verifies against the store and refuses a key revoked since install), card item 5, and its first consumer installed-plugin-loader.ts#dispatchPluginRuntime as os serve reaches it (serve.ts:4743 registerInstalledPlugins, after the config plugins loop, before bootstrap → loadInstalledPlugins → verifyInstalledPlugins → checkRecord).

Every sentence of the text on this head, checked in the tree:

  • "ENFORCED at load for a plugin os plugin install installed … dispatchPluginRuntime dispatches on the tier before it imports anything." HOLDS: readKept (kept sha, size cap) and checkRecord steps 3–4 (signature over the raw bytes against the resolved key with requirePlatform: false L341–346; bounded readTarGz L353; manifest id/version L369; tree byte-equal with no extra or non-regular entry L388) precede the dispatch at L392; the runtime dispatched on is the manifest inside the re-verified artifact, so an on-disk edit is tree_changed first; verifyInstalledPlugins imports nothing, and the import is loadInstalledPlugins L706 for verified items only.
  • "node is imported in process with a full PluginContext — author code in the host process, T0, NOT isolation." HOLDS: materializeStackPlugin(pathToFileURL(entry)), then kernel.use in installed-plugins-boot.ts; no enforcer scoping (M2 by the ADR's own phasing; the text does not claim the §3.6 table's "gated by declared capabilities" half).
  • "sandbox and worker are REFUSED by name until M3 routes them, with no in-process fallback." HOLDS: strict === 'node'; everything else not unset is runtime_not_dispatched; refused before the entry check and the import; the serve integration test boots a real os serve with a sandbox plugin and asserts its marker file was never written.
  • "an installed plugin that declares no tier is refused too (default-deny)." HOLDS for undefined, null, '' (runtime_undeclared); right against §3.6, the only reading under which "default-deny" is true at a local boot.
  • "So a declared sandbox is never run unconfined: today it is not run at all." HOLDS, the sentence that keeps the describe honest.
  • "The QuickJS runner … is not reached from a plugin's declared tier yet." HOLDS by census on this head (①.5).
  • "A plugin a stack's own plugins array names is the app's code and is not dispatched on this field." HOLDS: the config loop (serve.ts before L4743) takes modules and objects, reads no manifest, and runs first.
  • Field TSDoc, the round's sentence: "An artifact os plugin build produces carries code, so on one an unset tier is an omission … the boot-time loader refuses an installed plugin with no tier either way … and one with no entry module too." HOLDS (③.2).
  • Both describes still state both halves of the split (publish gate + HTTP 422; boot), as the 2026-08-30 ruling and the pin require; no "NOT ENFORCED" prose remains or is added. Card acceptance HOLDS.

Break attempts, each against the live tree: an on-disk manifest saying node over an artifact saying sandbox → tree_changed, then the artifact's tier; a --trust-unsigned record declaring sandbox → skips step 3 only, step 5 refuses; 'Node', 'node ', ['node'] → runtime_not_dispatched (and refused at build by the schema); an installed id colliding with a host plugin → plugin_name_taken before kernel.use; a programmatic @objectstack/runtime boot never loads installed plugins, so "only node is loaded" is never false there; os plugin install of a sandbox artifact installs with a warning and --json boot: { loads: false } — the describe claims enforcement at boot, not at install, no contradiction. None makes the text false. No must-fix on the hits file; the two earlier should-fixes on it are closed on this head.

The narrowing (the narrowing face, ruling 6442046166's question): does PluginLoader.verifyPluginSignature's refusal take a declared capability away at the consumer, or make the spec's own refusal real? It makes ADR-0025 §3.7 real: "PluginMetadata.signature exists … verify the chain at install (§3.5 step 4) and at load (§3.5 step 7)." On main the load-side half accepted any well-formed string and logged it "well-formed", so a module's own signature read as verified; on this head the only path to a passing signed plugin is the host that verified the bytes recording its verdict, which the loader does at inspectImported (L649) after refusing a module whose own field disagrees (signature_conflict, three shapes). A malformed signature is still refused first; an unsigned plugin is untouched; the spec's signature field, the enum and every authorable value are unchanged. The one capability taken away is "set plugin.signature and have the kernel log it as verified", which was never a declared one, and the Migration paragraph prescribes its replacement by name. Break attempts: a host that verifies its own artifact but records nothing → refused with the prescription in the message; a plugin that changes its signature after the verdict → verdict.signature !== plugin.signature refuses; a frozen plugin object → signature_conflict before kernel.use (and toPluginMetadata's default-writing would throw on main too, so nothing new); two copies of core → fails closed, kernel_refused; in-process code calling the public recorder → stated as not a boundary, consistent with §3.6's T0 definition; LiteKernel → never checked, unaffected. The narrowing is right, and its declaration now stands.

The second narrowing (①.7, the shared reader in publish) is right in substance — one reader for the artifact format, bounded, refusing what it cannot represent, instead of two readers that drift — and its disclosure is one command short (② should-fix).

Implemented-by: claude/project-thread-02c5oc
Reviewed-by: session_01Utp6CXc5hequf76uJfzrdK

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract-review-claim: session_01Utp6CXc5hequf76uJfzrdK · head 820d2772a6cc5fa2cd9c5af4ef52202764bbe4f9 · 2026-10-29T11:04Z

Round-3 head (findings-only commit over 766b96ff, PASS 6493264083); the same isolated reviewer agent takes the CONTRACT_REVIEW_TIER delta review on this head for the lane seat (Bill 2026-10-29T00:46Z: this account now serves that tier).


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 820d2772a6cc5fa2cd9c5af4ef52202764bbe4f9
Local-runs: none
Scope: delta over 766b96ff (the PASS record 6493264083, whose scope, inputs and judgments this record carries forward and does not restate): git diff 766b96ff 820d2772, 8 files, +107/−37 — packages/runtime/src/plugin-installation-store.ts, packages/runtime/src/installed-plugin-loader.ts, packages/cli/src/utils/installed-plugins-boot.ts, .changeset/27123-boot-time-plugin-loader.md, content/docs/plugins/index.mdx, and the three test files (plugin-installation-store.lock.test.ts, installed-plugin-loader.test.ts, installed-plugins-boot.test.ts) + packages/runtime/src/index.ts and packages/core/src/security/index.ts (unchanged, confirmed), the hits file packages/spec/src/kernel/manifest.zod.ts (unchanged this round, confirmed by the file list), the PR body at /mnt/project-files/server-seat3/27374-pr-body-r4.md and the dev report at /mnt/project-files/issue-27123/os-dev-report-s2-r3.json. The two faces are the same as on 766b96ff: the schema face (hits = manifest.zod.ts, untouched) and the narrowing face (Clause-②: no (narrowing), line unchanged); on the narrowing face every non-test path of the delta was read for a second accept-set or public-surface change.

Check-runs on 820d2772 at 11:05Z (their conclusions are the gate verdicts). Completed success: Check Changeset, Check PR Size, Governed Surface Queue Guard, PR-body guards, Plan Test Core shards, Spec property liveness, Dogfood Regression Gate, Check Documentation Links, Flag docs affected by code changes, Lint · PM dispatch-gates, Auto Label, filter. Path-skipped as before. Still in_progress at this read, named so this record does not stand in for them: Lint & Repo Gates' remaining jobs (Lint · CI, release & engine gates, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, the three Lint · scoped · jobs), Build Core, Build Docs, Build UI Deps, Live E2E UI, Test Core (1/8) through (8/8), Type Check · source gates, Type Check · debt ledger, Type Check · consumer gates, Type Check · workspace. None failed. The delta changes no spec, core or lint source, so check:generated, check:liveness and check:adr-0087-registration judge the same inputs they passed on 766b96ff; Test Core is where the three changed test files run; the landing waits for every required context to report success.

① Derived judgments

  1. No accept set of @objectstack/spec moves. manifest.zod.ts is not in the delta; the pin and the liveness row are untouched. Metadata-protocol: none stands.
  2. No public surface moves. packages/runtime/src/index.ts and packages/core/src/security/index.ts are not in the delta; readLockFile, clearStaleLock and both thrownMessage helpers are module-private. No command's flags, codes or output shape change: the lock-shaped refusals still surface as installations_locked (the PluginInstallationsLockedError type is unchanged), the unreadable-lock refusal is the same plain Error with a new reason clause.
  3. The one behaviour change is in an unreleased surface. readLockFile now lstats the lock path and refuses by name any non-regular file (a directory, a symbolic link dangling or not), where the round-2 reader followed a link; and a lock that is absent at the read (EEXIST on create, ENOENT on read — the dangling-link shape, or a release between the two calls) is now retried under the 10 s deadline with the 50 ms pause and refused at the deadline naming it, where round 2 spun with continue. The installations lock was introduced by this PR, so no published accept set narrows; a lock this store makes is a regular file (openSync(…, 'wx')), so the only path the lstat rule refuses is one the store never made. Read in the tree: removeIfUnchanged and clearStaleLock reach the lock through the same reader, so a symlinked .steal path is refused the same way rather than followed. RIGHT; read, not reviewed.
  4. thrownMessage at both sites now returns String(error instanceof Error ? error.message : error) inside its guard, so an Error whose message is a Symbol no longer throws inside the template literal that names the refusal; the rows added to the loader's import it.each and the serve wiring's loader_failed it.each pin it. No surface change.
  5. The steal-lock wording ("kept up to the lock's stale bound") now matches what judgeLock always did (the 5 min age row applies to the steal lock as to the lock); the new test (a live pid's steal lock older than the bound is cleared) pins the clause round 2 had and overstated. RIGHT.
  6. content/docs/plugins/index.mdx qualifies the signature bullet: a signed install is verified at install and at every boot; a --trust-unsigned install has no signature to verify and at boot is checked by the kept artifact's recorded hash and the tree against it. Checked against the loader: readKept compares sha256(artifact.osplugin) with the record and returns signature: null for a trustUnsigned record (step 3 skipped), then step 4 compares the tree — exactly the sentence. RIGHT.

② Semver level

  • The Clause-②: no (narrowing) line on the PR body and the core changeset is byte-identical to 766b96ff; the **BREAKING** banner, the one adr-0087: not-required (runtime-interface-only packages/core/src/plugin-loader.ts#PluginLoader) marker, the Migration paragraph and the patch level are unchanged; Metadata-protocol: none on both changesets is unchanged; the spec changeset is untouched. Check Changeset is success on 820d2772. RIGHT, as on 766b96ff.
  • Should-fix 6 of 6493264083 (the os plugin publish disclosure) is now right. The core changeset's reader sentence names os plugin publish beside install: its preflight "now refuses an artifact that inflates past 256 MiB or carries a link, device, extension-header or cut-short entry, where the old reader inflated without bound and read every entry as a file; os plugin build writes regular files only". The PR body carries it three times: the archive-reader entry under What changes (L212, with "no artifact it built is refused"), the maintainer read's 风险与回滚 line, and round-3 item 6, which also states the integrity-map qualification the record measured (with a map, a non-regular entry was already extra_file; without one it was accepted and is now refused). Each sentence is true of the tree (publish.ts:110 → readTarGz through the osplugin.ts re-export; the reader's refusals as read on 766b96ff). The arm, banner, disposition and level do not move, as the record said they need not.
  • No new changeset is owed: the delta publishes nothing beyond what the two changesets already describe.

③ Boundary flags

  1. Should-fix 6 (6493264083 ②, the publish disclosure). RESOLVED (② above).
  2. Should-fix 7 (6493264083 ③.14, packages/lint/src/lint-liveness-properties.ts L217 and L295). DEFERRED to slice 3 with the reason stated in the body (the live-elsewhere branch fires for no row on this head, so no author sees the stale example today; slice 3 carries the liveness follow-ups). The record graded it should-fix, carried, "in slice 3 or beside the docs follow-up", so the deferral is inside what the record allows; it stays open on the card. packages/lint/CHANGELOG.md is untouched (release-owned). ANSWERED.
  3. The seat's r3 must-fix (dangling symlink lock spun forever). Read in the tree as ①.3; the two tests (a dangling-link row in the unreadable-lock it.each, asserting the refusal names the link and the function never ran, with lstat proving the link is left; a vanish-on-every-read fault through the node:fs seam, waited out for 300 ms with fewer than 20 reads and refused naming the lock) pin both halves; the anti-vacuity runs are the dev's own. Not a hits file; nothing in the hits file speaks to the lock.
  4. Dev report r3: open_questions: [], out_of_scope_findings: []; one deviation, item 2 above. premise_still_valid: true; the merge base is unchanged (10f27b65). Its dogfood_battery line reads a complete run on 820d2772 (the first run cut by a session time limit, not a failure, is said so); Dogfood Regression Gate is green on this head.
  5. Size. 4781 changed lines (44 files, +4389/−392): still Tier H by size whatever this record says.
  6. Scope conformance to claim 6485671080: the delta touches runtime, cli, one changeset and one docs page; no os plugin build edit, no docs/adr/** (queue guard green), nothing in objectstack-ai/cloud, nothing under packages/spec or packages/core. HOLDS.
  7. installed-plugin-loader.test.ts budget (seat nit 5). The dev measured it under the 10 s line (3.57 s of tests alone); the file gained one it.each row this round. No header is owed on that reading; Test Core on this head will show the CI figure. Not a hits file.
  8. Carried, unchanged: the card's Tier H docs follow-up (four ADR-0025 edits), the i18n-extract should-fix (6484915007 item 3), and nightly-tiers: red on main #27114 (json-stdout-purity, the devx holder's; plugin-trust-read.ts is not in the delta).

④ Design judgment

packages/spec/src/kernel/manifest.zod.ts (the schema face): not in the delta; every sentence judged on 766b96ff holds on 820d2772, because its consumers (dispatchPluginRuntime, checkRecord's order, serve.ts's wiring, install.ts's dispatch read) are untouched — the loader's one changed line is inside thrownMessage. The record 6493264083's ④ stands as written.

The narrowing (the narrowing face): packages/core/src/plugin-loader.ts and plugin-artifact-verdict.ts are not in the delta; the judgment that PluginLoader.verifyPluginSignature's refusal makes ADR-0025 §3.7's load-side verification real stands. The second narrowing (the reader shared by os plugin publish) is now disclosed where the record asked; its design judgment (one bounded reader for the format, right in substance) stands.

The delta itself, judged as a change to the lock it belongs to: refusing a lock path the store could never have made (lstat, not stat) is the same instinct as the round-2 "only ENOENT reads as no lock" rule, taken one step further to the shape that defeated it (a dangling link is EEXIST to create and ENOENT to read, so neither rule alone saw it); bringing the gone-when-read branch under the same deadline and pause removes the one unbounded path left in the loop. The cost is a 50 ms pause on an ordinary release race, which the callers (one-shot commands and the pre-kernel boot) can pay. No must-fix.

Implemented-by: claude/project-thread-02c5oc
Reviewed-by: session_01Utp6CXc5hequf76uJfzrdK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

席位核对 — ACCEPT · domain:server#3 · session_01Utp6CXc5hequf76uJfzrdK · head 820d2772 · 2026-10-29T11:25Z

  • Shape: 44 files, +4389/−392, net against main at 10f27b65. Part of 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: this is slice 2 of ruling B′, ADR-0025 §3.5 M1, the boot-time loader for installed plugins. The card stays open for slice 3.
  • Seat review rounds, all closed on the tree (findings files r1–r4 at /mnt/project-files/server-seat3/27374-seat-review-r*.md):
    • r1 and r2: every item fixed.
    • r3 must-fix: a dangling symlink at <installations>.lock spun the lock loop forever. Fixed in 820d2772. readLockFile now lstats the lock and refuses any non-regular path by name. The gone-on-read path waits under the 10 s deadline with the 50 ms pause.
    • r3 nits: String() in thrownMessage at both sites; the steal lock's stale bound is now stated correctly and tested; --trust-unsigned is described as hash-only in plugins/index.mdx; the budget was measured (loader 3.57 s, lock 5.74 s), so no header is owed.
    • The thread mutation-checked all four fixes, and each test goes red without its fix.
    • r4, the delta over 766b96ff: no new items.
  • Contract review (CONTRACT_REVIEW_TIER, schema face + narrowing face):
    • PASS on a6a7e490 (6487771335).
    • FAIL on af0b3888 (6490914228). Its must-fix was the undeclared accept-set narrowing at kernel.use; it is resolved.
    • PASS on 766b96ff (6493264083).
    • Delta PASS on this head (6495004904). Should-fix 6 is resolved: os plugin publish is now named as sharing the bounded reader.
  • Declaration:
    • @objectstack/spec is patch, Clause-②: no. The change is describe text, TSDoc, the pin and the liveness row (runtime live-elsewhere → live).
    • @objectstack/cli, @objectstack/runtime and @objectstack/core are patch, Clause-②: no (narrowing), with a BREAKING banner. PluginLoader.verifyPluginSignature now refuses a signature that no host recorded a verdict for. The changeset carries adr-0087: not-required (runtime-interface-only …#PluginLoader) and a Migration paragraph (recordPluginArtifactVerdict before kernel.use).
    • All are Metadata-protocol: none.
  • Carried, not dropped:
  • Gates: all 50 check-runs on 820d2772 completed, 41 success and 9 skipped, with no failure. The PR is mergeable (clean).
  • Landing: Tier H by size (4781 lines). Ready → the governed review request goes to os-zhuang and hotlong; auto-merge is on, so the PR merges on their approval.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 29, 2026
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit d4ff1a0 Oct 29, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-02c5oc branch October 29, 2026 11:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants