Repository navigation
Commit d4ff1a0
domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKURKuRGF1ewEVVZKx7U1i4)_
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`
签名检查不再只看格式,必须有宿主记录的验证结论。
- **为什么改:** 这是 #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**
2. **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)`.
3. **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.
4. **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.
5. **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.
6. **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).
7. **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 stays `patch` with
`Metadata-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 (#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**
3. **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.
4. **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.
5. **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.
6. **"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.
7. **Off-surface files** are listed under Acceptance notes, with why
each was needed.
**Seat review, nits.** All taken.
8. 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`).
9. 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).
10. 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`.
11. **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).
12. **`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.
13. `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 #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`:**
- Problem: the `live-elsewhere` criteria block mutates
`manifest/runtime` and expected it to carry the verdict.
- Fix: each case now plants the row as it was before #27123, in its copy
of the ledgers. The planted row is attested today, so it stays inside
the 180 d window, and the copy's `manifest` count shard is moved by that
one row.
- The shipped-ledger control stays: green, with the population line
printed. A new planted control asserts the row is counted and satisfied.
- **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:
0. `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.
1. **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`).
2. **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.
3. **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`.
4. **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`).
5. **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.use`s 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
#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](https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a1d8bf6 commit d4ff1a0
44 files changed
Lines changed: 4389 additions & 392 deletions
File tree
- .changeset
- content/docs
- plugins
- protocol/kernel
- references
- api
- kernel
- packages
- cli
- src
- commands
- plugin
- trust
- utils
- test
- core/src
- security
- lint/src
- runtime/src
- spec
- liveness
- state-counts
- scripts/liveness
- src/kernel
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
312 | 312 | | |
313 | 313 | | |
314 | 314 | | |
315 | | - | |
| 315 | + | |
316 | 316 | | |
317 | 317 | | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
318 | 332 | | |
319 | 333 | | |
320 | 334 | | |
321 | 335 | | |
322 | 336 | | |
323 | 337 | | |
324 | | - | |
325 | | - | |
326 | | - | |
327 | | - | |
328 | | - | |
329 | | - | |
330 | | - | |
331 | | - | |
332 | | - | |
333 | | - | |
334 | 338 | | |
335 | 339 | | |
336 | 340 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
22 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
23 | 24 | | |
24 | | - | |
25 | | - | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
26 | 28 | | |
27 | 29 | | |
28 | 30 | | |
| |||
201 | 203 | | |
202 | 204 | | |
203 | 205 | | |
204 | | - | |
205 | | - | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
206 | 211 | | |
207 | 212 | | |
208 | 213 | | |
| |||
375 | 380 | | |
376 | 381 | | |
377 | 382 | | |
378 | | - | |
379 | | - | |
| 383 | + | |
| 384 | + | |
380 | 385 | | |
381 | 386 | | |
382 | 387 | | |
| |||
390 | 395 | | |
391 | 396 | | |
392 | 397 | | |
393 | | - | |
394 | | - | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
395 | 404 | | |
396 | 405 | | |
397 | 406 | | |
| |||
920 | 929 | | |
921 | 930 | | |
922 | 931 | | |
923 | | - | |
| 932 | + | |
924 | 933 | | |
925 | 934 | | |
926 | 935 | | |
| |||
0 commit comments