Skip to content

feat(cli, runtime, platform-objects): os plugin install verifies a signed .osplugin against the environment's trusted keys and installs it locally (#27123, slice 1 of 3) - #27335

Merged
os-zhuang merged 9 commits into
mainfrom
claude/project-thread-02c5oc
Oct 29, 2026
Merged

os-zhuang merged 9 commits into
mainfrom
claude/project-thread-02c5oc

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 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 — no authorable key or accepted value changes; packages/spec gains the sys_trusted_key row of PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] (the registry's maintenance contract, a platform-object name list) and its liveness integrity row moves to live; the rest is TSDoc prose
Metadata-protocol: none

维护者速读

  • 改了什么: 新增 os plugin keygen、os plugin trust add|list|revoke、os plugin install ./x.osplugin,以及部署级系统对象 sys_trusted_key(只存公钥)。install 先按清单 integrity 逐文件复核,再用库里该 key id 的公钥验 .sig(verifyPluginArtifact 的第一个生产调用方),全部通过才解包到 <OS_HOME>/plugins/<env>/<id>/<version>/,并写安装记录(key id + 指纹)。
  • 为什么改: 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′ 的第 1 片(ADR-0025 §3.5 M1 本地 T0 安装闭环)。此前签好名的 .osplugin 没有任何命令能安装它。
  • 风险与回滚: 只新增命令、一张部署级表和两个 runtime 存储模块;os serve 还不加载已安装的插件(那是第 2 片),所以运行时行为不变。和 feat(platform-objects): sys_exchange_rate — an organization's dated exchange rates, admin-written and audited, with a Setup list under Localization #27246 在 platform-objects 注册清单及其测试上会有文本冲突,后合的一方取两边并集即可。回滚 = revert 本 PR。
  • 先看: packages/cli/src/commands/plugin/install.ts(拒绝的顺序和名字);packages/runtime/src/plugin-trusted-keys.ts(一个 key id 只对应一把钥匙、吊销后不能复活);packages/platform-objects/src/system/sys-trusted-key.object.ts。
  • 两分钟验证: pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/plugin/install.integration.test.ts,预期 16 个全绿:信任后安装成功;未知键、已吊销键、键行不可用、签名对不上字节、没有签名、签名格式坏、签名读不了、制品路径读不了、integrity 不符、越界路径、安装记录文件坏、另一个项目占着同一目录,都按名字拒绝;每个用例自建目录和库,顺序无关(打乱顺序跑、单独跑都绿)。
  • 需要你做的: 需要你批准合并:本 PR 改动 3096 行(+3016 / -80,含生成文件),超过 3000 行的人工合并线,按 Tier H 落地,席位在 CI 绿、契约审查通过后来请你批。ADR-0025 §3.5 v2 里 "Steps 4–7 do not exist" 那句随卡上的 Tier H 文档跟进去改,本 PR 不碰 docs/adr/**。

What changes

sys_trusted_key (packages/platform-objects/src/system/sys-trusted-key.object.ts) — the environment's trusted plugin-publisher keys, as ruled: key_id (unique 'global'), public_key (SPKI PEM), fingerprint (sha256: + hex of the SPKI DER bytes, unique 'global'), source (local now; platform / cloud reserved and written by nothing), status (active | revoked), added_by, added_at. Deployment-level by ADR-0131 D7: systemFields: { tenant: false }, requiredPermissions: ['manage_platform_settings'], managedBy: 'engine-owned', API get / list only, so no API caller can add or revive a key. Platform root keys are not rows.

  • Registration home (ADR-0029 D1 / D2). PlatformObjectsPlugin's first manifest, beside sys_secret, on the project database. Its readers are the install loop itself, os plugin install now and the boot-time loader of slice 2, and no capability plugin owns that loop: the loader runs at boot, before any optional plugin would register the table, and every one-shot boot the commands take composes PlatformObjectsPlugin. So it is core infrastructure under D2, registered where its siblings are; no capability plugin's own list changes.

Runtime stores (@objectstack/runtime, exported because the CLI writes them and the slice-2 loader is to read the same ones):

  • plugin-trusted-keys.ts — readPublisherPublicKey refuses a PEM holding a private key (before createPublicKey, which would quietly derive the public half), an unreadable one, and a key that is not Ed25519, naming its type. addTrustedKey writes in system context and keeps one key id ↔ one key: a different key under a taken id, the same key under a second id, a revoked id, or a revoked key under a new id are each refused by name; the same key under the same id again writes nothing. revokeTrustedKey keeps the row (status: 'revoked'). resolveTrustedKey answers active (with the key) / revoked / unknown / invalid, the last when the stored fingerprint is not the stored PEM's.
  • plugin-installation-store.ts — pluginInstallDir(env, id, version) = <OS_HOME>/plugins/<env>/<id>/<version>/, refusing an id or version that is not one safe path segment. The record (id, version, runtime, status, grantedPermissions, dir, artifact sha256, signature: { keyId, fingerprint } | null, trustUnsigned, installedAt) is kept in <OS_HOME>/plugin-installations/<packageStateFileName(env, cwd)>, the package disable-state store's own file-name rule (ADR-0016 §9.7), written by temp file + rename. An unreadable record file is refused, never rewritten from empty.

Commands (packages/cli/src/commands/plugin/):

  • os plugin keygen --out PATH [--key-id ID] [--force] — writes PATH.key.pem (PKCS#8, mode 0600) and PATH.pub.pem (SPKI), prints the key id (default: the first 16 hex digits of the fingerprint), the fingerprint, and the sign / trust add lines. Never overwrites either file without --force. The private key never leaves the developer's disk.

  • os plugin trust add PUB.pem --key-id ID, trust list [--json], trust revoke ID — admin writes through a plain one-shot boot with PlatformObjectsPlugin + AuditPlugin, so each write lands in sys_audit_log by the audit mirror; list takes the read-only boot (deferSchemaDdl + readOnlyProbe, absent table read as empty).

  • os plugin install ARTIFACT [--sig PATH] [--trust-unsigned] [--json] — in order, every refusal named and exit 1, nothing written before the last check passes:

    1. the archive (readTarGz): artifact_not_found, artifact_unreadable (also an artifact path that is a directory or cannot be read), artifact_entries_refused (absolute, .., empty-segment, backslash / NUL, non-normal, duplicate or case-folded duplicate, file-and-directory, or reserved entry paths, the reserved names in any letter case), manifest_unreadable, manifest_incomplete, manifest_unsafe_identity;
    2. integrity at unpack (verifyIntegrity): integrity_malformed, integrity_mismatch (one line per violation); an absent map is the manifest's permissive pass;
    3. the signature: signature_not_found (an explicit --sig that is not there), signature_unreadable (a .sig that is a directory or cannot be read, never taken for an absent one, so --trust-unsigned cannot install around it), signature_missing, signature_malformed; the key id resolved in the store through the read-only boot: signature_key_untrusted, signature_key_revoked, trusted_key_invalid; then verifyPluginArtifact with requirePlatform: false: signature_invalid / signature_verification_failed.
    4. before anything is written: this project's installation records are read, and a file it cannot read is refused (record_unreadable) rather than clobbered by the write; an install directory that already holds other bytes (its artifact.osplugin sha256 differs, or it has none) that no record of this project owns is refused (install_dir_taken): two projects sharing one OS_HOME and environment id share <OS_HOME>/plugins/<env>/<id>/<version>/ under the ruled layout, and one must not overwrite the other's install. Installing the very bytes the directory holds, or replacing a directory this project's record owns, goes ahead.
    5. materialize (staging dir + rename, so a failed write leaves no half install, and a failed swap puts the earlier install back and removes the new tree), keeping artifact.osplugin and artifact.osplugin.sig beside the files; then the record.

    --trust-unsigned applies only to an artifact with NO signature: it installs with a warning on stderr and records trustUnsigned: true. A present signature is always verified; the flag never makes a bad one pass (pinned). Per triage 6463804227 there is no "unsigned with warning" arm without the flag. The environment id is OS_ENVIRONMENT_ID, else env_local. The human face ends by saying os serve does not load installed plugins yet.

Self-descriptions slice 1 makes false, corrected (prose only):

  • packages/spec/src/kernel/manifest.zod.ts — PluginIntegritySchema TSDoc ("NOT RE-VERIFIED AT UNPACK … there is no os plugin install") and the integrity field's TSDoc now say os plugin install re-verifies at unpack. No .describe() text changed.
  • packages/core/src/security/index.ts, plugin-artifact-integrity.ts; packages/cli/src/utils/osplugin.ts, commands/plugin/publish.ts, commands/plugin/sign.ts — comments naming the "future runtime loader" as the only re-verifier.
  • packages/cli/README.md ("The group has no install") and content/docs/protocol/kernel/lifecycle.mdx (the top callout, the Plugin Installation callout, What an author does today, and the Summary's Atomic Installations line, each of which said there is no os plugin install or that code-plugin installation is not implemented) — the three commands listed, and os serve named as not loading installed plugins yet.
  • packages/spec/liveness/manifest.json — the integrity row moves dead → live with in-repo evidence install.ts#PluginInstall and publish.ts#PluginPublish; its note had assigned that flip to whatever built re-verification at unpack. liveness/state-counts/manifest.md regenerated (gen:liveness-counts), and liveness/README.md's manifest row no longer counts integrity among the dead.
  • Left as they are, true until slice 2: PluginRuntimeSchema "nothing dispatches on the tier", plugin-loader.ts's format-only signature check, plugin-artifact-signature.ts.

Changesets: .changeset/27123-local-plugin-install.md (@objectstack/cli, @objectstack/runtime, @objectstack/platform-objects, patch) and .changeset/27123-integrity-reverified-at-unpack.md (@objectstack/spec, @objectstack/core, patch, prose only); both carry the two declaration lines.

Tests

  • packages/runtime/src/plugin-trusted-keys.test.ts (14) — against a real ObjectQL + SqlDriver over a SQLite file holding the real sys_trusted_key, never a double: the PEM refusals (a private key, with the control that node itself would derive a public key from it; RSA, naming 'rsa'; non-PEM); add / idempotent re-add / key-id-invalid / key-id-taken / key-trusted-under-another-id; revoke keeps the row and refuses both key-id-revoked and key-revoked afterwards; resolveTrustedKey hands back a key that verifies what its private half signed, and answers invalid for a row whose fingerprint is another key's (with the anti-vacuity control that the PEM itself is a good key); and the table itself refuses a second row of the same key, and a second key under the same id, written around the store (two adds racing), with the control that a second key under a second id is a row.
  • packages/runtime/src/plugin-installation-store.test.ts (10) — the install path, six unsafe id / version segments refused, round trip and replace-by-id, the project-keyed file name equal to packageStateFileName, and a corrupt record file refused and left byte-identical.
  • packages/cli/src/utils/plugin-install.test.ts (28) — every unsafe entry path named once; materialize keeps the original bytes and signature, writes no .sig for an unsigned install, and a re-install replaces the old tree whole with no staging dir left; two paths differing only in letter case; a file that is also another entry's directory (three shapes, one case-folded), with a control; the reserved names in other letter case; installedArtifactSha's three answers; a swap that fails at either rename leaves the earlier install whole and no staging dir, and a leftover from an earlier failed swap does not block the next (each of the three fails against the pre-fix code).
  • packages/cli/test/plugin-keygen.test.ts (4) — the pair is one Ed25519 key, private half 0600, the printed fingerprint is the public half's, the private key's text never reaches the terminal, the default key id, --force, and an unusable key id writing nothing.
  • packages/cli/src/commands/plugin/install.integration.test.ts (16, integration tier) — in process through oclif, real SQLite, every case on its own OS_HOME, project and database, so the cases hold in any order (green shuffled, --sequence.shuffle, and one at a time): an untrusted key refused and no database file created; trust add writes the row and an audit row, re-add writes nothing, a private key is refused; a signed install materializes and records the key; signature_invalid; trusted_key_invalid (the row's PEM swapped under its fingerprint); artifact_unreadable (an artifact path that is a directory); signature_malformed; signature_unreadable (a .sig directory, with and without --trust-unsigned); integrity_mismatch; artifact_entries_refused; signature_missing then --trust-unsigned installing with the stderr warning; --trust-unsigned still refusing an untrusted, a malformed, an other-bytes and a revoked signature; signature_key_revoked after trust revoke with the earlier install left in place; record_unreadable leaving the corrupt file byte-identical; and two projects on one OS_HOME: the second is refused install_dir_taken with the first's tree untouched, the owner may replace, and installing the bytes already there goes ahead.
  • packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts — the four new bootSchemaStack callers declared in CALLERS (the first case reads the family off the source, so a caller left out of the table is named there): plugin install and plugin trust list as no-write modes (database byte-identical, no SQLite file brought into existence, one JSON document), plugin trust add and plugin trust revoke as write modes (no seed write), all four leaving the key home empty. A @FIXTURE@ placeholder carries a signed artifact and public key written in beforeAll, and one added case pins that the install fixture reaches the store read (signature_key_untrusted) rather than refusing earlier, so its no-write cases cannot hold vacuously.
  • Updated: packages/platform-objects/src/plugin.test.ts (registration roster gains sys_trusted_key) and deployment-level-no-tenant-column.test.ts (the deployment-level population gains it).

Acceptance notes

  • Store transport. The commands reach sys_trusted_key through the CLI's one-shot bootSchemaStack (read-only for install / list, plain for add / revoke), the family the os migrate / os secret commands use, against --database-url / OS_DATABASE_URL or the project's default database. No HTTP route was added.
  • Slice 2 reads what this writes. The boot-time loader is to read the installation record (loadPluginInstallations), re-verify artifact.osplugin against artifact.osplugin.sig with resolveTrustedKey, and refuse a key revoked since install. Nothing here loads or dispatches on runtime.
  • Key id, not fingerprint, is the lookup. install resolves the signature's key id, then resolveTrustedKey re-derives the fingerprint from the stored PEM and refuses a mismatch; the record keeps both.
  • Merge collision with feat(platform-objects): sys_exchange_rate — an organization's dated exchange rates, admin-written and audited, with a Setup list under Localization #27246 (draft, sys_exchange_rate): both PRs append to PlatformObjectsPlugin's first manifest and to the same roster assertion in plugin.test.ts. Both also move scripts/platform-object-tenancy-census.json. Whichever lands second takes the union in the two files (five objects, "The five above") and regenerates the census with node scripts/platform-object-tenancy-census.mjs --write.
  • ADR text. ADR-0025 v2 §3.5 still says "Steps 4–7 do not exist … there is no os plugin install". It is not edited here (no docs/adr/** edit); it rides the card's Tier H docs follow-up.
  • File surface beyond the commands and the object (all inside the slice): packages/cli/src/utils/organization-ownership-inventory.ts (the ADR-0131 D10 inventory row for sys_trusted_key: census platform-census-main, fate column-drop, as sys_secret); scripts/platform-object-tenancy-census.json (derived, regenerated with --write: the new object joins the population, 87 → 88 registered, out of reach as deployment-level); packages/runtime/src/plugin-trusted-keys.ts, plugin-installation-store.ts and their index.ts exports; packages/cli/src/utils/plugin-trust-store.ts, plugin-install.ts; the one-shot family pin; the self-description comment edits in packages/core/src/security/*, packages/cli/src/utils/osplugin.ts, publish.ts, sign.ts, packages/cli/README.md, content/docs/protocol/kernel/lifecycle.mdx; packages/spec/src/system/constants/platform-object-names.ts (the registry row) and packages/spec/liveness/ (manifest.json, state-counts/manifest.md, README.md).
  • Size: Tier H. 3096 changed lines (+3016 / -80, generated included) against the 3000 human-merge threshold, measured by dispatch-gates on 6b3a8286; review round 1 added about 400, mostly tests (integration 10 → 16, unit 17 → 28). It lands on an authorized APPROVED review or a human merge.
  • Metadata-protocol: none, measured. sys_trusted_key is an engine-owned system object with a read-only API; no metadata type, authorable key or accepted value is added (check:authorable-surface and check:api-surface green, the spec's 14 generated artifacts current with nothing to regenerate).
  • Premises 1–3 of 6464984121, re-run on origin/main 9ff8154c: verifyPluginArtifact( had one hit, its definition (plugin-artifact-signature.ts:264); on this branch the second is install.ts. sign.ts reads --key / --key-id and writes only the signature. allowedSigners outside packages/spec: 0 hits.

Known limitations

  • The installations file's read-modify-write takes no lock. Two os plugin install runs for one project and environment at the same moment can each read the file, and the second rename wins, dropping the first's record (its tree stays on disk). The write itself is atomic (temp file + rename), so no reader sees half a file. A lock is out of this slice.
  • gunzip has no size cap before the signature check. readTarGz inflates the whole artifact before its signature is verified, so a hostile .osplugin can make the command spend memory before it is refused. The signature covers the compressed bytes, so a cap belongs on the inflate, not after it.
  • Two projects that installed identical bytes share ownership. install_dir_taken refuses bytes no record of this project owns; when both projects' records name the same sha, either may later replace the tree, and the other's record then names a sha the directory no longer holds. The slice-2 loader is to check the kept artifact's sha256 against the record before it trusts the tree, which refuses that case at boot.

Verification

Review round 1, at head ae1b0536 (the CI red: PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] lacked sys_trusted_key, reproduced locally and fixed), each inside scripts/pm/os-verify-lock.sh, every VERDICT command-exit 0:

  • runtime plugin-trusted-keys + plugin-installation-store — Test Files 2/2, Tests 24/24.
  • pnpm --filter @objectstack/platform-objects exec vitest run — Test Files 97/97, Tests 1369/1369.
  • cli unit plugin-install + plugin-keygen + exit-signal pin — Test Files 3/3, Tests 154/154.
  • cli integration install.integration + one-shot family pin — Test Files 2/2, Tests 140/140.
  • pnpm --filter @objectstack/spec run test:repo — Test Files 84/84, Tests 1396/1396.
  • cli tsc --noEmit clean; eslint --no-inline-config over the touched files exit 0; check:liveness exit 0.
  • node scripts/pm/dispatch-gates.mjs --ran … — 119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN; every one exited 0.

First round, at head 70cdda4e:

All at head 70cdda4e (origin/main ebfadb02 merged in), each inside scripts/pm/os-verify-lock.sh, every VERDICT command-exit 0:

  • pnpm --filter @objectstack/runtime exec vitest run src/plugin-trusted-keys.test.ts src/plugin-installation-store.test.ts — Test Files 2/2, Tests 23/23.
  • pnpm --filter @objectstack/platform-objects exec vitest run — Test Files 97/97, Tests 1369/1369.
  • pnpm --filter @objectstack/cli exec tsc --noEmit -p tsconfig.json — no errors.
  • pnpm --filter @objectstack/cli exec vitest run src/utils/plugin-install.test.ts test/plugin-keygen.test.ts test/exit-signal.pin.test.ts — Test Files 3/3, Tests 143/143 (the exit-signal pin discovers the four new commands on its own).
  • pnpm --filter @objectstack/cli exec vitest run --project integration src/commands/plugin/install.integration.test.ts src/utils/schema-migrate.one-shot-family.integration.test.ts — Test Files 2/2, Tests 134/134.
  • Before the merge, at a0c22f13: pnpm --filter @objectstack/runtime exec vitest run — Test Files 473/473, Tests 8598 passed, 19 skipped.
  • pnpm --filter @objectstack/spec check:generated --fix — "All 14 generated artifacts are up to date", nothing regenerated (the spec edit is TSDoc only).
  • eslint (--no-inline-config) over packages/cli packages/runtime packages/platform-objects packages/core packages/spec/src/kernel — exit 0.
  • node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack — "117 derived famil(ies) accounted for — 117 run, 0 NOT-MEASURED". Every one of the 117 exited 0. 104 of them ran on the pre-merge tree; the five that first exited non-zero were re-run on 70cdda4e, together with the eight the merged tree adds:
    • pnpm check:platform-object-tenancy-census — exit 1 on the first run (sys_trusted_key new to the population), then 0 after --write;
    • check:skill-examples, check:dual-build-cjs-loads, check:i18n-coverage — exit 3 (no build output) on the first run, then 0 after a full turbo run build;
    • check-plugin-teardown-shape --self-test — exit 3 (a shallow clone without its pinned fixture commit), then 0 after fetching it.
  • Manual run of the loop by hand (isolated OS_HOME, --database-url file:…): keygen → install refused untrusted (no app.db created) → trust add (private key refused; idempotent) → install → tamper / unsigned / malformed / escape refusals → --trust-unsigned → revoke → install refused revoked → trust add of the revoked key refused. sys_audit_log carried the create and update rows for the key.

Generated by Claude Code

…1 — trusted-key store, keygen, trust, install

Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD
Co-authored-by: Claude <noreply@anthropic.com>
…ts, one-shot family entries

Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD
Co-authored-by: Claude <noreply@anthropic.com>
…verified at unpack, os plugin install exists)

Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD
Co-authored-by: Claude <noreply@anthropic.com>
…istration pin; field copy names no ADR

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 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

77 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 6abcb72b0f20257b075149a1fabcaba884648b8b.

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

What this run could not see
  • 9 changed file(s) yielded no anchor (packages/cli/README.md, packages/cli/src/utils/osplugin.ts, packages/core/src/security/index.ts, …) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: isSystem (5 routes)
  • 23 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 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 — 184 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 6abcb72b0f20257b075149a1fabcaba884648b8b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b20695e193c1eed3a01b6ca5f1dcb8ec4a7acb7e — the merge of head 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f into base 6abcb72b0f20257b075149a1fabcaba884648b8b, 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 b20695e193c1eed3a01b6ca5f1dcb8ec4a7acb7e && git checkout b20695e193c1eed3a01b6ca5f1dcb8ec4a7acb7e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6abcb72b0f20257b075149a1fabcaba884648b8b 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f && git checkout -B drift-repro 6abcb72b0f20257b075149a1fabcaba884648b8b && git merge --no-ff 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f

node scripts/docs-audit/affected-docs.mjs --json 6abcb72b0f20257b075149a1fabcaba884648b8b

⚠️ 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 6abcb72b0f20257b075149a1fabcaba884648b8b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ore any write, swaps safely, and lists sys_trusted_key in the registry (#27123)

The CI red: `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lacked the
`sys_trusted_key` row its maintenance contract requires.

Review round 1:
- the installations file is read before anything is written, and one it
  cannot read is refused (`record_unreadable`) instead of being found only
  after the tree was materialized;
- the install-directory swap restores the earlier install and removes the
  new tree when either rename fails, and clears a leftover from an earlier
  failed swap first;
- an install directory holding other bytes that no record of this project
  owns is refused (`install_dir_taken`): two projects on one OS_HOME and
  environment no longer overwrite each other's install;
- `sys_trusted_key.fingerprint` is unique `'global'`;
- archive entries differing only in letter case, a file that is also
  another entry's directory, and the reserved names in any case are refused
  by name;
- a `.sig` (or artifact) path that is a directory or cannot be read is
  refused by name (`signature_unreadable`, `artifact_unreadable`);
- lifecycle.mdx's three remaining passages and the liveness `integrity`
  row (now `live`, counts regenerated) say what the install does;
- the integration cases each build their own home, project and database,
  and cover --trust-unsigned with untrusted, malformed, other-bytes and
  revoked signatures, signature_malformed and trusted_key_invalid.

Claude-Session: https://claude.ai/code/session_01Utp6CXg4K8dxetvqvU81bD
Co-authored-by: Claude <noreply@anthropic.com>
With sys_trusted_key now in PLATFORM_OBJECTS_BY_PACKAGE, the inventory's
enumeration pin named it as a platform object without a fate. It is
deployment-level (systemFields.tenant: false, ADR-0131 D7), so its row is
the column-drop fate its siblings sys_secret and sys_flow_credential carry.

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

席位核对 — ACCEPT · domain:server#3 · session_01Utp6CXc5hequf76uJfzrdK · head 6b3a8286 · 2026-10-28T23:03Z

  • Shape: slice 1 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 under ruling B′ (6467932167): os plugin keygen, os plugin trust add|list|revoke, os plugin install, the deployment-level sys_trusted_key, the runtime trusted-key and installation stores. 3,096 changed lines (+3,016 / −80) ⇒ Tier H on size (over the 3,000 human-merge line); no governed path. packages/spec is touched: TSDoc prose in kernel/manifest.zod.ts, the liveness integrity row (dead → live, counts regenerated), and one registry row PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] += sys_trusted_key (non-prose; the cross-domain exception the order granted, declared in the PR).
  • Ruling conformance: exactly slice 1: no boot loader, no tier dispatch, no manifest-deps retirement, no platform-root rows, no cloud fetch; no unsigned-with-warning arm without --trust-unsigned (triage 6463804227); ADR text left to the card's Tier H docs follow-up.
  • Seat review: round 1 at 70cdda4e (an isolated reviewer, nothing must-fix in the security core: verify-before-write over the exact bytes, key id ↔ fingerprint binding, revoked/private-key refusals, path-safety). Eight should-fixes, all done at ae1b0536 and read in the delta: records validated before any write; the rename swap restores the previous install and removes staging on failure; install_dir_taken for a dir another project's bytes own; stale lifecycle.mdx and liveness text corrected; fingerprint index unique: 'global'; case-folded duplicate and file-vs-directory entries refused by name; signature_unreadable; integration 10 → 16 cases, each isolated. Two known limitations stated in the PR body (record write has no lock; gunzip has no size cap before the signature check).
  • CI fixes since: spec registry row (test:repo), then the ADR-0131 D10 inventory row in packages/cli/src/utils/organization-ownership-inventory.ts (column-drop, as sys_secret).
  • Gates: on 6b3a8286 43 success, 11 skipped, none other; mergeable_state: clean. Dev's derived gates 119/119 exit 0.
  • Declarations: Clause-②: no · Metadata-protocol: none; the spec registry row is for the contract reviewer to judge against that line.
  • Contract review owed: packages/spec/src/** (non-test) is the published-schema face ⇒ CONTRACT_REVIEW_TIER; no record on this head → needs:contract-review on the PR.
  • Landing: Tier H — after a same-form PASS on this head, on a maintainer's APPROVED review or human merge.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f · 2026-10-29T00:46Z

Triage seat, taking needs:contract-review on this head (#27062 (a)). The review runs at CONTRACT_REVIEW_TIER. If the lane claims or posts a record on this head first, triage withdraws.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-bill · session_01Utp6CXc5hequf76uJfzrdK

Contract-review-claim: session_01Utp6CXc5hequf76uJfzrdK · head 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f · 2026-10-29T00:47Z

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: 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f
Local-runs: none
Scope: packages/spec/src/kernel/manifest.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/cli/src/commands/plugin/install.ts, packages/cli/src/utils/plugin-install.ts, packages/cli/src/commands/plugin/publish.ts, packages/core/src/security/plugin-artifact-integrity.ts, packages/core/src/security/plugin-artifact-signature.ts, packages/spec/liveness/manifest.json, packages/spec/src/system/constants/platform-object-names.test.ts, packages/spec/src/stack.zod.ts, packages/objectql/src/engine-lifecycle-health.ts, packages/platform-objects/src/plugin.ts, packages/platform-objects/src/system/sys-trusted-key.object.ts, docs/adr/0025-plugin-package-distribution.md (read, not reviewed). The remainder of the diff (cli commands and utils, runtime stores, platform-objects object and tests, changesets, census, README, lifecycle.mdx) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs: card #27123 (body + all 10 comments, ruling 6467932167 letter B′, claim 6469792198, os-dev-report 6480186227 at the earlier head 70cdda4e), PR #27335 body + 37-file list + git diff origin/main...cr-27335 at merge-base ebfadb02, and the check-runs on the head. The check-runs are the gate verdicts: every completed run is success or path-skipped (Lint UI, Dogfood Verify CLI, opt-in harnesses); none failed. All ten required contexts report success except Lint UI, which is skipped because no ui/ path moves. Governed Surface Queue Guard, Spec property liveness, Check Changeset, Check PR Size are green. Author objectstack-fleet[bot]; no out-of-relay login to name.

① Derived judgments

  1. PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] gains sys_trusted_key (platform-object-names.ts:92). Derived: PLATFORM_PROVIDED_OBJECT_NAMES gains one member and isPlatformProvidedObjectName('sys_trusted_key') flips false → true. Readers on the head, each judged:
    • stack.zod.ts ~L4704 (validateCrossReferences): an action whose objectName is sys_trusted_key was refused at the merge-base and is admitted on this head. RIGHT: the object is registered unconditionally by PlatformObjectsPlugin's first manifest on this head (plugin.ts:197, objects: [SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey]), and the registry's maintenance contract is exactness. Leaving the row out would make a real platform object a refused reference, which the registry header names as worse than the heuristic it replaced, and platform-object-names.test.ts ("registers exactly the objects each package declares") reds the omission since sys-trusted-key.object.ts declares the name.
    • packages/lint rung ③ (validate-object-references.ts:238, validate-nav-access.ts:162, validate-translation-references.ts:1848): a reference to sys_trusted_key is now a known platform object instead of an advisory probable-typo. RIGHT, same ground.
    • engine-lifecycle-health.ts:107 (resolvePrimaryDatasource): the new name now votes when registered. RIGHT: it rides the same manifest as sys_secret with no defaultDatasource triple (plugin.ts keeps the three siblings' no-triple rule, test pins infra.defaultDatasource undefined), so it binds where sys_secret binds and cannot split the primary.
    • dangling-reference-audit.ts:383 derives from the plugin-security group only; unaffected. RIGHT.
    • organization-ownership-inventory.test.ts:123-156 requires an inventory row per registered object; the diff adds the sys_trusted_key row (fate column-drop, as sys_secret). RIGHT.
    • @objectstack/spec api-surface: the export's declared type (a readonly record of string to readonly string array) does not move; a value member is added. The gate (check:api-surface, in the green TypeScript Type Check job) agrees. RIGHT that nothing on the export surface moves.
  2. Clause-② direction on item 1 (ruled here, as the at-tier review owns the direction): the widening of the reference-resolution accept set is DERIVATIVE of the object's registration in platform-objects, not a relaxation of the spec's own authorable vocabulary; the registry is a name list that tracks what the platform registers, and no metadata key, accepted value or type is added. The declared Clause-②: no stands. For the record, a yes (widening) reading would move neither the level (none kind ⇒ patch either way) nor the tier (already CONTRACT_REVIEW_TIER on the schema face). The PR body and the spec changeset both disclose the row in their ② line, which is the right form.
  3. manifest.zod.ts: PluginIntegritySchema TSDoc and the ManifestSchema.integrity field TSDoc are rewritten; the .describe() strings are byte-identical on both sides ('Per-file content digests of the plugin artifact (ADR-0025 §3.2)'), PluginIntegritySchema stays z.record(z.string(), z.string()), the field stays .optional(), and PluginPackagingSchema / PluginRuntimeSchema are untouched on this head. Derived: no accept-set change, no served-body change, no generated-docs change (check:docs and check:generated in the green TypeScript Type Check job agree, and the dev's check:generated --fix reported 14/14 current). RIGHT.
  4. packages/spec/liveness/manifest.json: the integrity row flips dead → live with in-repo evidence install.ts#PluginInstall and publish.ts#PluginPublish; state-counts/manifest.md moves 27/14 → 28/13 and liveness/README.md's manifest row stops counting integrity among the dead. The anchor class PluginInstall exists on the head and calls verifyIntegrity before any write; Spec property liveness is green. RIGHT. The row's own note had reserved the flip for whatever built unpack-time re-verification, which this PR is.
  5. Public surface outside spec, named for completeness (lane seat's files, judged only for the registry row's truth): new deployment-level object sys_trusted_key (tenant: false, manage_platform_settings, engine-owned, API get/list only, unique indexes on key_id and fingerprint); nine new @objectstack/runtime exports; four new CLI commands. Observation: SysTrustedKey is not imported into packages/platform-objects/scripts/i18n-extract.config.ts (which imports SysSetting, SysSecret, SysSettingAudit, SysMigration from system/index), so its labels are outside en.objects.generated.ts and outside the coverage ratchet's population (that ratchet is rooted on extract configs, so check:i18n and check:i18n-coverage are green by construction). Four sibling deployment-level objects (sys_platform_setting, sys_platform_setting_audit, sys_migration_journal, sys_metadata_activation) share the omission on main, so this is precedent-consistent and not a hits finding; carried as a should-fix note for the lane seat under ③.

② Semver level

  • .changeset/27123-integrity-reverified-at-unpack.md: @objectstack/spec patch, @objectstack/core patch; Clause-②: no — …, Metadata-protocol: none. Matches the diff: the spec change is TSDoc prose plus one registry-row value; core is comment prose only. none kind ⇒ patch by the Post-Task Checklist rule; the fixed group in .changeset/config.json versions spec/core/cli/runtime/platform-objects together regardless.
  • .changeset/27123-local-plugin-install.md: @objectstack/cli, @objectstack/runtime, @objectstack/platform-objects patch; Clause-②: no — …, Metadata-protocol: none. Matches: new commands, stores and a system object are platform code, no authorable surface moves (check:authorable-surface green). Level follows the kind (none ⇒ patch), as the rule prescribes even for a feature.
  • Check Changeset green on the head. The PR body carries the same two line-initial declarations.
  • Clause-②: line: no is correct as ruled in ①.2; no (narrowing) arm, so no BREAKING banner or ADR-0087 marker is owed. The card's own caveat (PluginPackagingSchema narrows to bundled) belongs to slice 3 and does not occur on this head.

③ Boundary flags

Dev flags (PR body "Known limitations") and report fields (open_questions: [], two out_of_scope_findings), each answered or escalated:

  1. Installations file read-modify-write takes no lock (two concurrent installs on one project/env can drop a record; the write itself is temp-file + rename). ANSWERED: accepted for M1 (local, first-party, operator-driven); should-fix, carried to card 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 for slice 2, which reads the same file at boot and is the right place to decide a lock or an append-only record. Not a hits file.
  2. gunzip has no size cap before the signature check. ANSWERED with a sharper note: verifyPublisherSignature (plugin-artifact-signature.ts:204) needs only the raw artifact bytes, the .sig string and the key, and parseSignature yields the keyId without the manifest, so the signature step can precede readTarGz entirely (the package_id/version fields feed only the platform counter-sign, which M1 sets requirePlatform: false); the one cost is booting the trust store before inflating. Should-fix for slice 2 or a follow-up; for M1 T0 the artifact is the developer's own, so this is not a must-fix. Not a hits file.
  3. Two projects that installed identical bytes share ownership of one dir; slice 2's loader is to check the kept artifact sha against the record. ANSWERED: accepted; carried to card 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 as a slice-2 acceptance line.
  4. out_of_scope 1: ADR-0025 v2 §3.5 still says "Steps 4–7 do not exist … there is no os plugin install" (read on main, L293-296). ESCALATED: the card's Tier H docs follow-up now owes three edits, not one — the §3.5 M1 ✅ line, the §7 dated note the ruling 6467932167 ordered, and this sentence. Correctly left out of the PR (no docs/adr/** path on the file list; queue guard green).
  5. out_of_scope 2: text collision with feat(platform-objects): sys_exchange_rate — an organization's dated exchange rates, admin-written and audited, with a Setup list under Localization #27246 on plugin.ts's first manifest, plugin.test.ts's roster and scripts/platform-object-tenancy-census.json. ANSWERED: noted; whichever lands second also owes its own platform-object-names.ts row, which platform-object-names.test.ts enforces, so the union cannot land silently wrong.
  6. Reviewer's flag (from ①.5): SysTrustedKey outside the platform-objects i18n extract config. ESCALATED as a should-fix note for the lane seat on card 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, precedent-consistent with four siblings; a fix is one import line plus a bundle regeneration, better done once for all five.
  7. Size: 3096 changed lines (+3016 / −80, generated included) exceeds the 3,000-line human-merge threshold; 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. needs:contract-review comes off with this record.
  8. open_questions: [] — none to answer. Premises 1–3 of 6464984121 were re-run by the dev on 9ff8154c and hold on this head as read (verifyPluginArtifact( has exactly two call sites on the branch: its definition and install.ts).

④ Design judgment

packages/spec/src/kernel/manifest.zod.ts — judged against ADR-0025 v2 §3.2 / §3.5 M1, ruling 6467932167, card item 5, and the first consumer install.ts.

  • Claim "RE-VERIFIED AT UNPACK BY os plugin install … re-hashes the unpacked files against the map before it materializes anything": HOLDS. install.ts step 2 calls verifyIntegrity(files, manifest.integrity, { exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME] }) over the in-memory archive entries, before the signature step, the record read, and materializePlugin; materializePlugin writes exactly that files array (plugin-install.ts, for (const file of input.files) … writeFileSync(target, file.data)), so the hashed bytes are the written bytes.
  • Claim "refuses the install by name on the same three findings": HOLDS. verifyIntegrity emits digest_mismatch / missing_file / extra_file; install refuses integrity_mismatch with one formatIntegrityViolation line per finding and exits 1; publish.ts:141 runs the identical call with the identical exempt set, so the two readers agree on the covered set. A non-object map is refused earlier as integrity_malformed, a fourth refusal the TSDoc need not name since the schema already types the map as a record.
  • Claim "an absent map is the same permissive pass there": HOLDS. verifyIntegrity returns { ok: true, skipped: true } on undefined/null; install prints "per-file check skipped". Consistent with the field staying .optional() and with the ADR's "artifacts predating integrity computation stay publishable".
  • Claim "sits beside the install's signature check over the whole artifact, and neither replaces the other": HOLDS. Step 3 verifies the detached .sig with verifyPluginArtifact over the raw compressed bytes; a present signature is always verified (--trust-unsigned only covers an absent one); a tampered artifact whose map was rewritten to match is caught by the signature, and a corrupt-but-signed one by the map — the two mechanisms are independent, as the text says.
  • Claim "The cloud control plane stores the artifact blob opaquely and checks nothing against the map": carried over from the prior text (cross-repo, unmeasured here, as the liveness row's _note already records); the sentence narrows nothing and adds no new assertion about cloud.
  • Card acceptance "the listed self-descriptions are deleted in the same PRs; no new NOT ENFORCED prose is added": HOLDS for this slice. The "NOT RE-VERIFIED AT UNPACK … there is no os plugin install" block and the field's "enforce leg is unbuilt" sentence are gone; PluginRuntimeSchema's "nothing dispatches on the tier" is deliberately left, still true until slice 2 (the loader), as the claim 6469792198 scopes it.
  • Break attempts: an integrity map whose values are not strings is coerced via String() and mismatches → refused; a map naming the exempt objectstack.plugin.json is skipped at both readers, matching computeIntegrity's exclusion; an archive entry named artifact.osplugin (the kept original) is refused as reserved by archiveEntryProblems before step 2. No path makes the TSDoc false. No must-fix, no should-fix on this file.

packages/spec/src/system/constants/platform-object-names.ts — judged against the registry's own maintenance contract, its conformance gate, and the registering code.

  • The row's claim is "@objectstack/platform-objects registers sys_trusted_key". HOLDS on the head: sys-trusted-key.object.ts declares ObjectSchema.create({ name: 'sys_trusted_key', … }), plugin.ts registers it in the first (com.objectstack.platform-objects, scope: 'system') manifest unconditionally, and plugin.test.ts pins the four-object roster and the absence of the routing triple. The gate that enforces the row (platform-object-names.test.ts, a source scan of every *.object.ts per owning package) and the row are one change and were read as one; Test Core is green.
  • Placement: inside the platform-objects group, once, alphabetical between sys_team_member and sys_two_factor; the test sorts, so order is cosmetic. Every registered name carries the sys_ prefix, as the test requires.
  • Scenario it is accepted against (ruling B′: the install command resolves a signature's key id in sys_trusted_key through a one-shot boot that composes PlatformObjectsPlugin): the registry row makes a stack reference to the object resolve and lets the lifecycle-health vote count it; neither path is on the install's critical path, and both are correct as judged in ①.1.
  • Break attempts: a second package declaring the same name would red the conformance test (one-owner membership); a deployment that composes no PlatformObjectsPlugin sees the name unregistered and the health vote abstains by design ("no evidence either way"). No must-fix, no should-fix on this file.

Verdict on ④: no must-fix on either hits file. Should-fix items (lock, signature-before-inflate, i18n config import) are all in lane-seat files or future slices and are carried to the card under ③.

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

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6b3a8286e6ba18016b5d9d3f2a3d29cd84e2925f
Local-runs: none
Scope: packages/spec/src/kernel/manifest.zod.ts, packages/spec/src/system/constants/platform-object-names.ts + packages/cli/src/commands/plugin/install.ts, packages/core/src/security/plugin-artifact-integrity.ts, packages/cli/src/commands/plugin/publish.ts, packages/cli/src/utils/plugin-trust-store.ts, packages/platform-objects/src/system/sys-trusted-key.object.ts, packages/platform-objects/src/plugin.ts, packages/platform-objects/src/plugin.test.ts, packages/spec/src/system/constants/platform-object-names.test.ts, packages/spec/liveness/manifest.json, packages/spec/src/stack.zod.ts, packages/objectql/src/engine-lifecycle-health.ts, docs/adr/0025-plugin-package-distribution.md (§3.4, §3.5)

Inputs read: card #27123 (body and all 10 comments, ruling 6467932167 letter B′ and triage 6463804227 included), PR #27335 body, file list (37 files), net diff vs main at the head, and the 59 check-runs on 6b3a8286: 44 success, 15 skipped, none pending, none failed. Test Core 1/8–8/8, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Check Changeset, TypeScript Type Check all success. The card is #27123 (the brief recorded none; the PR body's Part of #27123 and Ruling-ref: 6467932167 name it). The rest of the diff (cli commands, runtime stores, platform-objects object and registration, core/cli comments, README, lifecycle.mdx, liveness ledger, census, changesets) is the lane seat's ACCEPT at TIER_DEFAULT (6482973281); it was read here to judge the two hits, not reviewed.

① Derived judgments

  • manifest.zod.ts changes no accept-set. Both hunks are TSDoc lines only (every changed line in the diff is a * comment line; no .describe(), no zod shape, no export changed). PluginIntegritySchema is still z.record(z.string(), z.string()), integrity still .optional(). check:generated (14 artifacts) is reported current and Lint & Repo Gates is green on the head. Right.
  • platform-object-names.ts adds one row: PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] gains 'sys_trusted_key' (49 → 50 names, inserted in alphabetical position between sys_team_member and sys_two_factor). Public-surface consequences: PLATFORM_PROVIDED_OBJECT_NAMES gains the name and isPlatformProvidedObjectName('sys_trusted_key') flips false → true. Right, and compelled: the file's maintenance contract ("adding an object to a platform package means adding its name here") is enforced by platform-object-names.test.ts, which scans every *.object.ts under packages/*/src and asserts sorted equality per owning package; sys-trusted-key.object.ts lives in packages/platform-objects/src/system/, so the row belongs in exactly this group. The PR body records the gate going red at ae1b0536 for the missing row and green after; Test Core is green on this head.
  • Accept-set changes the row implies, judged right. (a) stack.zod.ts (~L4697–4712): an action whose objectName is sys_trusted_key was refused on main as "not defined in objects" and is accepted at the head; (b) @objectstack/lint reference rules (validate-object-references, validate-nav-target-refs, validate-nav-access, validate-dataset-references, validate-translation-references, authoring-rules) stop flagging the name as a probable typo. Both follow the registry's one rule: the set is "objects a package in this repo registers", and PlatformObjectsPlugin.plugin.ts registers SysTrustedKey unconditionally in its first manifest (objects: [SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey]) in the same diff. A name the engine serves must not read as fictional; this is the same move as sys_comment_reaction (18.0.0) and sys_flow_dispatch (17.2.0).
  • Runtime consumer checked. engine-lifecycle-health.ts iterates PLATFORM_PROVIDED_OBJECT_NAMES for the primary-datasource verdict; sys_trusted_key is registered on the project database without the routing triple exactly as sys_secret / sys_migration are (plugin.test.ts pins infra.defaultDatasource undefined for the four), so it votes the way its siblings vote. No new verdict class. Right.
  • API surface. packages/spec/api-surface/system.json lists PLATFORM_OBJECTS_BY_PACKAGE (const) by symbol, which the row does not change; check:api-surface is inside the green Lint & Repo Gates. Right.
  • Not a spec change, read for completeness: @objectstack/runtime gains nine exports (addTrustedKey, listTrustedKeys, revokeTrustedKey, resolveTrustedKey, readPublisherPublicKey, publicKeyFingerprint, pluginInstallDir, loadPluginInstallations, recordPluginInstallation), @objectstack/platform-objects exports SysTrustedKey, and the CLI gains five commands. Lane remainder; named in the cli/runtime/platform-objects changeset.

② Semver level

  • .changeset/27123-integrity-reverified-at-unpack.md: @objectstack/spec patch, @objectstack/core patch. Its body names BOTH things the spec publishes — the TSDoc correction and the sys_trusted_key registry row, with its consequence for PLATFORM_PROVIDED_OBJECT_NAMES — and carries both declaration lines. Matches the diff.
  • .changeset/27123-local-plugin-install.md: @objectstack/cli, @objectstack/runtime, @objectstack/platform-objects patch, both declaration lines present, the runtime exports listed. Matches the diff (lane remainder; noted, not reviewed).
  • Level: AGENTS.md §Post-Task 3 — "the level follows the kind alone … none takes patch". Metadata-protocol: none is correct: no metadata type, authorable key or accepted value of any governed schema is added; the registry is a curated name list, not an authorable surface, and the one accept-set movement it causes (①) is the registry tracking an object that now exists. Patch is right. The Check Changeset gate is green on the head.
  • Clause-②: no on the PR body and both changesets, with no arm, and the PR body's line discloses the non-prose spec edit (the registry row and the liveness integrity flip) in so many words. Right: no narrowing (nothing an author could write is refused that was accepted), and the registry row is not a protocol widening — it is the registry's maintenance contract executing. The ruling itself (6467932167) pre-declared Clause-②: no · Metadata-protocol: none for the object and commands.
  • Note, not a finding: the PR body's summary line calls the spec/core changeset "prose only", while the changeset itself correctly says otherwise. The changeset is the input the release reads, and it is right.

③ Boundary flags

  • Dev report 6480186227: open_questions: []. Nothing to answer there.
  • Out-of-scope finding 1 — ADR-0025 v2 §3.5 still says "Steps 4–7 do not exist … there is no os plugin install". Read at the head (L293–296): true, the text is now stale. The order (6469792198) forbade docs/adr/**, the card's Acceptance assigns the §3.5 M1 ✅ line to a Tier H docs follow-up, and the ruling adds the §7 dated note to the same follow-up. Answered — rides the card's follow-up, not this PR. The two hits files do not cite the stale sentence; manifest.zod.ts cites "§3.5 M1", which is the bullet that describes what this slice built.
  • Out-of-scope finding 2 — merge collision with feat(platform-objects): sys_exchange_rate — an organization's dated exchange rates, admin-written and audited, with a Setup list under Localization #27246 on plugin.ts's first manifest, plugin.test.ts's roster and the tenancy census. Answered in the PR body (union, "five objects", census --write); the registry row in platform-object-names.ts will collide the same way (both add a name to the same group) — the same union rule covers it, and the spec conformance test refuses a bad merge.
  • File surface beyond the order. The claim listed manifest.zod.ts "describe text only" as the spec surface; the diff edits TSDoc (narrower — no .describe() touched) and adds the registry row, which the order did not list. The lane seat flagged this for the contract reviewer. Judged: right and unavoidable — the object cannot be registered without the row (the spec gate fails, as it did at ae1b0536), and it is declared on the PR body's Clause-② line and in the spec changeset. Not an escalation.
  • Known limitations in the PR body (no lock on the installations file; no gunzip size cap before the signature check; identical-bytes shared ownership). All three sit in install.ts / the runtime store, the lane's remainder. None contradicts a claim in either hits file: the TSDoc asserts no ordering between the integrity and signature checks and nothing about concurrency. Accepted as stated; slice-2 loader owes the sha re-check the third one names.
  • Tier H. 3,096 changed lines against the 3,000 human-merge line; the lane seat's landing rule (maintainer APPROVED review or human merge after a PASS) stands. This record is the PASS it waits on; it does not merge.
  • Triage 6463804227 ("no unsigned-with-warning arm") — install.ts refuses signature_missing without --trust-unsigned, and a present signature is always verified with or without the flag. Holds in the tree.

④ Design judgment

packages/spec/src/kernel/manifest.zod.ts — judged against the card's item 5 / Acceptance ("delete the self-descriptions as they stop being true; no new NOT ENFORCED prose"), the ADR-0025 §3.5 M1 bullet it cites, and its first consumer, install.ts#PluginInstall.

  • Every sentence of the new TSDoc checked against the live tree: (1) "RE-VERIFIED AT UNPACK BY os plugin install" — install.ts step 2 calls verifyIntegrity(files, declaredIntegrity, { exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME] }) over the archive entries readTarGz returned. (2) "before it materializes anything" — step 2 precedes step 5 (materializePlugin), and every refusal returns before any write. (3) "refuses the install by name on the same three findings" — verifyIntegrity emits digest_mismatch / missing_file / extra_file, the three the preceding paragraph names for publish; install.ts refuses integrity_mismatch with one formatIntegrityViolation line per finding, exit 1. (4) "an absent map is the same permissive pass there" — verifyIntegrity returns ok, skipped for undefined/null, and install.ts passes the raw manifest value through. (5) "sits beside the install's signature check over the whole artifact, and neither replaces the other" — step 3 verifies verifyPluginArtifact over the raw artifact bytes against the resolved sys_trusted_key row; both checks run regardless of --trust-unsigned. (6) "The cloud control plane stores the artifact blob opaquely and checks nothing against the map" — ADR-0025 §3.4 v2: "Not built (M3): … per-file integrity re-verification on the server (the server checks the whole-artifact hash and does not unpack)"; same claim the old text carried, now consistent with the ADR's record (cloud repo not re-measured this round; the sentence is the ADR's). The integrity field TSDoc (L1057–1061) repeats (1), (6) and the publish preflight. Holds.
  • Residual stale prose checked: the file's remaining "not implemented" sentences are PluginRuntimeSchema's load-side tier enforcement (L200, L217, L1021, L1029), which slice 1 does not build — still true, and the PR body says it leaves them. No "NOT ENFORCED" prose was added. Holds.
  • Tried to break it: the TSDoc describes the install's verifier as running on "the unpacked files", while install.ts hashes the in-memory archive entries and writes them afterwards — the same bytes, and the stronger property (nothing on disk before the check). The malformed-map arm (integrity_malformed, a non-object map in the raw compiled manifest) is not in the TSDoc; it cannot pass ManifestSchema anyway, so the schema's own text need not name it. Neither is a finding.
  • No must-fix. No should-fix.

packages/spec/src/system/constants/platform-object-names.ts — judged against the file's own maintenance contract, the gate that enforces it (platform-object-names.test.ts), the ruling B′ it serves (sys_trusted_key as a platform object), and its consumers.

  • The object exists and is registered: sys-trusted-key.object.ts declares ObjectSchema.create({ name: 'sys_trusted_key', … }) with the ruled fields (key_id, public_key, fingerprint, source local|platform|cloud, status active|revoked, added_by, added_at), isSystem, managedBy: 'engine-owned', systemFields: { tenant: false } (ADR-0131 D7), requiredPermissions: ['manage_platform_settings'], API get/list only, unique: 'global' on both key_id and fingerprint; plugin.ts registers it in the first manifest (scope: 'system') and system/index.ts exports it. The conformance scan maps the file to package platform-objects (owningPackage: the segment before src), so the row is in the right group, and the group is sorted-equality checked — green on this head.
  • Placement is alphabetical, matching the group's convention (the gate compares sorted sets, so order is a readability matter only).
  • Registration home against ADR-0029 D1/D2: no capability plugin owns plugin distribution, the readers (the CLI one-shot boot via trustStorePlugins, which composes PlatformObjectsPlugin; the slice-2 boot loader) run before any optional plugin registers, so the D2 "core infrastructure" reading beside sys_secret holds; the registry row follows the registration.
  • Tried to break it: (a) a source: 'platform'|'cloud' row that nothing writes — a reserved value on an engine-owned, read-only-API object; it does not touch the registry. (b) The engine health voter (①) — behaves as sys_secret. (c) The CLOUD_PROVIDED_OBJECT_NAMES gate — sys_trusted_key is declared in this repo and not listed there; the test's "declared here — move it out" assertion is unaffected. (d) Docs that enumerate the group or its count — none found under content/docs or package READMEs. None is a finding.
  • No must-fix. No should-fix.

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

VERDICT: PASS


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 10f27b6 Oct 29, 2026
63 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-02c5oc branch October 29, 2026 01:43
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 protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants