Skip to content

fix(cloud-connection)!: install-local reads the manifest of a compiled artifact as os package install sends it, so the protocol handshake runs on it - #28603

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-0kdxhp
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-0kdxhp

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28535

Clause-②: no (narrowing) — POST /api/v1/marketplace/install-local refuses, with the 422 OS_PROTOCOL_INCOMPATIBLE (or DEPENDENCY_MISSING) a flat manifest already got, a compiled artifact sent with id and version lifted beside its nested manifest (the body os package install ./dist/objectstack.json sends) whose engines.protocol excludes this runtime or whose dependencies this runtime does not satisfy. That body installed before. Nothing is widened.
Metadata-protocol: none
Skills-docs: none — the pages already state the behaviour this restores: skills/objectstack-platform/SKILL.md ("The runtime checks it before it loads anything and refuses an out-of-range app at the boundary") and content/docs/protocol/kernel/plugin-spec.mdx ("the pre-install dependency check every install door runs"). No page described the CLI path as an exception.

BREAKING accept-set narrowing at POST /api/v1/marketplace/install-local (@objectstack/cloud-connection), reached by os package install ./dist/objectstack.json.

维护者速读

  • 改了什么:离线安装接口(POST /api/v1/marketplace/install-local)现在按「body 里是否有嵌套的 manifest 对象」识别编译产物并展平,不再要求顶层没有 id/name。os package install ./dist/objectstack.json 发来的产物因此会被展平,协议握手(engines.protocol)和依赖检查(dependencies)都读得到真实值。CLI 不改。
  • 为什么改:卡 os package install ./dist/objectstack.json: the body leaves engines nested under manifest.manifest, so install-local logs "declares no engines.protocol range" and loads an artifact built for ^18 without the ADR-0087 protocol check #28535(分诊 6554172575):CLI 把 id、version 提到顶层,正好绕过了服务端的展平条件,engines 留在下一层,服务端只记一条「未声明 engines.protocol」警告就装上了。用真实 CLI 二进制实测:^17、^19 的产物都装成功、退出码 0。
  • 风险与回滚:只收窄「协议不兼容 / 依赖不满足」的 CLI 产物安装,答复与平铺 manifest、POST /packages 完全一致;^18 和不声明 engines 的产物照旧安装(^18 不再出现那条误报警告)。目录(catalog)路径的已发布产物本来就没有顶层 id,行为不变。回滚即 revert。
  • 先看:packages/cloud-connection/src/marketplace-install-local-plugin.ts(normalizeBundle 改为调用已有的 flattenPackageBody);packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts(新 [#28535] 用例组);.changeset/28535-install-local-nested-artifact-manifest.md。
  • 两分钟验证:pnpm --filter @objectstack/cloud-connection exec vitest run src/marketplace-install-local-protocol-handshake.test.ts src/marketplace-install-local-dependency-missing.test.ts → 28/28 通过。
  • 需要你做的:无,席位落地(收窄需先过合约评审)。

What changes

Which side owns the shape, and why the server. The measurement (below) separates the two candidates. On origin/main, the bare compiled artifact { manifest: { …, engines }, objects } posted to install-local is already flattened by the door and refused 422 (the refuses the bare compiled artifact case passes on base). Only the body the CLI builds escapes: install.ts spreads the artifact and lifts id and version to the top, and the door's normalizeBundle flattened only when the body had NO top-level id and name. So the intended contract is the door's (ADR-0130 D4: a compiled artifact nests its manifest under manifest:; the door already flattens it on the inline and catalog branches, and flattenPackageBody already flattens it for the unmanaged import). The defect is that the door's detection depended on which sender built the body. Fixing the door closes it for every CLI already published, which all send the lifted shape; a CLI-only fix would leave every shipped CLI bypassing the handshake. ⛔ No second handshake: the one assertProtocolCompat (packages/metadata-core/src/protocol-handshake.ts) at step 1c is unchanged; it now receives the flat manifest.

The door (packages/cloud-connection/src/marketplace-install-local-plugin.ts). normalizeBundle becomes isPlainObject(m) ? flattenPackageBody(m) : m: a body with a nested plain-object manifest is a compiled artifact, flattened { ...meta, ...sections } (sections win, the order flattenPackageBody records). The old guard (!m.id && !m.name && (m.manifest.id || m.manifest.name)) is gone. One flatten now serves the inline branch, the catalog branch and the unmanaged import. For the CLI body the lifted top-level id/version equal the nested ones (artifact.manifest.id ?? artifact.id), so the flattened identity does not change.

What else stayed nested, and now is read. Reading every top-level manifest key handleInstall reads: engines (handshake, step 1c) and dependencies (ADR-0141 D2 check, step 1c') were both one level down for the CLI body. installModes and namespace were already read through declaredPackageManifest, which reads the nested manifest itself, so they were right before and stay right. packages (multi-package artifacts) is a collection beside the manifest and was already top level. name now reaches the registered package for the CLI body (it was nested before).

Not changed: packages/cli/src/commands/package/install.ts (the lift is now harmless against this runtime; dropping it would also let a new CLI get the check from an older runtime, but that is a second side and outside the ruling's "fix the side that owns the contract"). PR #28571's handleUninstall hunks and open PR #28554's hunks are untouched (re-read at start and before pr_create; #28554's nearest hunk is 30 lines from flattenPackageBody, which this PR does not edit).

Tests

  • marketplace-install-local-protocol-handshake.test.ts, new group [#28535] a compiled artifact as os package install sends it. The body is built exactly as install.ts builds it (cliInstallBody). Cases: ^17 refused 422 with the five diagnostic fields and nothing registered, synced or ledgered; the CLI body answers byte-for-byte what the flat manifest answers; the bare compiled artifact is refused the same way (control, green on base too); ^18 installs with NO [protocol] warning, registered and ledgered flat (engines top level, no manifest key); no engines installs with exactly one warning, as before.
  • marketplace-install-local-dependency-missing.test.ts, new group: the CLI body with an absent dependency is refused 422 DEPENDENCY_MISSING and nothing is written; the lit control installs once the dependency is installed in range.

Red on base (plugin source swapped to origin/main a2e75562, mutation proved on disk: flattenPackageBody(m) count 0, old guard count 1; restored with git checkout HEAD --, git diff HEAD 0 bytes, blob hash equal to HEAD): 4 failed / 24 passed of 28. Failing exactly: refuses ^17, answers exactly what the flat manifest answers, lit control: ^18 installs with no protocol warning (the false warning), and the dependency refusal. The bare-artifact and no-range controls pass on both trees. Green on 8454de72: 28/28. Full package: pnpm --filter @objectstack/cloud-connection test 63 files / 779 tests passed; typecheck and build exit 0. This revert is also the ablation: the tests import the plugin by relative path (source, no dist), and reverting the one changed expression reds the ^17, ^18-no-warning and dependency cases, which is the whole behaviour change.

Acceptance notes

  • Ledger entries written by earlier builds through the CLI carry the lifted shape with engines nested. Measured with a one-off probe (not kept) in this package's harness: a ledger entry in that shape declaring ^17 is re-registered at kernel:ready with no error and no [protocol] line (registered = [..., "com.example.qaold"], errors = 0, warns = []), because the rehydrate's checkProtocolCompat(entry.manifest) reads the entry as stored. Not changed here: the claim's surface is the install read, and refusing an already-installed package at boot is its own narrowing for deployments that hold such entries. Reported to the seat as a finding. Reinstalling the package through the door now judges it.
  • declaredPackageManifest and flattenPackageBody at the unmanaged-import and install-mode steps now always receive a flat body on both branches, so they act as identity there. Left as they are: they still state where each shape keeps its keys.

Verification

Real CLI binary, before and after. packages/cli/bin/run.js (built dist) running os package install ./dist/objectstack.json --runtime … --email … --password … against a real os start --artifact host.json (development posture, OS_CLOUD_URL=off). Four artifacts, each { manifest: { id, namespace, version, type, name, engines? }, objects: [one object] }. Script: pm-orders/server/evidence/28535/measure.mjs in the project files.

Base (origin/main a2e75562):

=== v17 exit=0   srv| WARN [MarketplaceInstallLocal] [protocol] package 'com.example.pv17' declares no engines.protocol range; loading under protocol 18.0.0 without a compatibility check (ADR-0087).
=== v19 exit=0   srv| WARN … package 'com.example.pv19' declares no engines.protocol range …
=== v18 exit=0   srv| WARN … package 'com.example.pv18' declares no engines.protocol range …
=== none exit=0  srv| WARN … package 'com.example.pnone' declares no engines.protocol range …
LEDGER [ com.example.pnone.json, com.example.pv17.json, com.example.pv18.json, com.example.pv19.json ]

Head (8454de72, cloud-connection rebuilt):

=== v17 exit=1   cli| ✗ Install failed (422 OS_PROTOCOL_INCOMPATIBLE): package 'com.example.pv17' targets protocol ^17 (engines.protocol) but this runtime is protocol 18.0.0. This is a major-version break. Run: objectstack migrate meta --from 17
=== v19 exit=1   cli| ✗ Install failed (422 OS_PROTOCOL_INCOMPATIBLE): package 'com.example.pv19' targets protocol ^19 (engines.protocol) … Upgrade the runtime to protocol 19 …
=== v18 exit=0   cli| ✓ Package installed into the running kernel   (no [protocol] warning)
=== none exit=0  cli| ✓ Package installed into the running kernel   srv| WARN … 'com.example.pnone' declares no engines.protocol range …
LEDGER [ com.example.pnone.json, com.example.pv18.json ]

Gates on 8454de72. dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 60 families for the 4 changed paths; each ran with its exit code captured before any pipe, and --ran answers ✓ dispatch-gates --ran: 60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED. All 60 exit 0. Two first ran into prerequisites and were re-run, not counted green on the first reading: check:dual-build-cjs-loads (exit 3, no full dist/; after the full packages build: 115 published require entry point(s) across 70 package(s) load) and check:type-check-debt (exit 3, lock queue-timeout behind this PR's own dogfood run; re-run under the lock: exit 0). Record: pm-orders/server/evidence/28535/gates-ran-8454de72.txt. eslint packages/cloud-connection --no-inline-config: exit 0.

Dogfood battery (owed: (narrowing) arm) on 8454de72, under the lock, pnpm --filter @objectstack/dogfood test after the ^... closure build: VERDICT command-exit 0, Test Files 364 passed / 3 skipped (367), Tests 2934 passed / 28 skipped (2962).

CI: the families CI runs are read from this PR's check-runs; not re-run locally.


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5mLNytcuq6kvRwXHMq

…d artifact as os package install sends it

The door flattened a compiled artifact only when the body had no top-level
id or name. The CLI lifts id and version beside the nested manifest, so
engines and dependencies stayed one level down and the ADR-0087 handshake
and the ADR-0141 dependency check never read them. Detect the artifact by
its nested manifest object alone, through flattenPackageBody.

Claude-Session: https://claude.ai/code/session_01FEkQ5mLNytcuq6kvRwXHMq
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 213 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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 — 5 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 ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8454de72440b7bef7a411e4899b8d126f3f5f09c
Local-runs: none
Scope: packages/cloud-connection/src/marketplace-install-local-plugin.ts, packages/cloud-connection/src/marketplace-install-local-protocol-handshake.test.ts, packages/cloud-connection/src/marketplace-install-local-dependency-missing.test.ts + packages/cli/src/commands/package/install.ts (the sender, read on main), packages/metadata-core/src/protocol-handshake.ts (assertProtocolCompat, unchanged), packages/spec/src/kernel/manifest.zod.ts (ManifestSchema), packages/qa/dogfood/test/install-local-*.dogfood.test.ts (six files that build the lifted body), the plugin hunks of open PRs #28587 and #28554

Face: narrowing — record-recognisers --paths on the four changed paths decides no face (no published schema, no governed path); the changeset's Clause-②: no (narrowing) line alone sets it. No further face is owed.

Ruling read: card #28535, triage 6554172575 ("measure with the CLI binary first, then fix whichever side owns the shape"; no second handshake, the 422 OS_PROTOCOL_INCOMPATIBLE that POST /packages gives), claim 6554580917, the seat's order, dev report 6555096778. The dev measured with the real binary on base and head (transcripts quoted in the PR body) and chose the door: on main the bare compiled artifact is already flattened and refused, so the shape the door was built for is ADR-0130 D4's and only its detection guard was sender-dependent. That reading is right: fixing the CLI would leave every shipped CLI bypassing the handshake, and the ruling named the side that owns the contract.

① Derived judgments

  1. The one hunk. normalizeBundle was m && !m.id && !m.name && isObject(m.manifest) && (m.manifest.id || m.manifest.name) ? { ...meta, ...sections } : m; it is now isPlainObject(m) ? flattenPackageBody(m) : m, where flattenPackageBody (L545) is isPlainObject(body.manifest) ? { ...meta, ...sections } : body. Same spread, same order; only the guard changed. Correct as described.

  2. Exactly which bodies move. A body whose manifest is a plain object AND which carries a top-level id or name — that is the shape install.ts L171-173 builds ({ ...artifact, id: artifact.manifest.id ?? artifact.id, version: … }) — was not flattened before and is now. Everything below that read a manifest-top-level key on that body now reads the real value: engines (step 1c, assertProtocolCompat), dependencies (step 1c', checkDeclaredDependencies), and name reaches the registered package. So the accept-set narrows by exactly: the lifted CLI shape whose engines.protocol excludes this runtime (422 OS_PROTOCOL_INCOMPATIBLE) or whose dependencies are unmet (422 DEPENDENCY_MISSING). Nothing widens: no body that was refused before is admitted now (the old guard only ever withheld a flatten; it refused nothing). Declaration Clause-②: no (narrowing) correct.

  3. Conflict rule between a top-level key and the nested manifest's key. { ...meta, ...sections } — the top-level (section) key wins. For the CLI body the lifted id/version are copies of the nested ones, so identity is unchanged; the new lit control case pins registered.id, registered.engines, registered.manifest undefined and the ledger file's flat engines. For a hand-built body where they differ (no first-party sender builds one), the top-level value wins — and that is the order flattenPackageBody already applied at the unmanaged import (L3159) and the order its doc cites from AssembledPackageBodySchema. Before this change, declaredPackageManifest (install mode, namespace) read the NESTED value on such a body while the unmanaged import copied the TOP value; now both read the same flat body, so the two readers agree. One flatten, one rule: matches.

  4. Unchanged paths. A flat manifest has no manifest key (ManifestSchema is a strictObject, L485, with no such key), so flattenPackageBody returns it as is — identity, exactly as before. The bare compiled artifact (no lifted id) was flattened by the old guard and is flattened by the new one — identical result; the refuses the bare compiled artifact case is a control green on base. A multi-package artifact (packages[], no nested manifest at the top) is untouched on both branches, and the 1c' per-entry read of entry.manifest is unchanged. The catalog branch (normalizeBundle(data?.manifest)) applies the same rule: a published payload is the compiled bundle without a lifted id, flattened before and now; one that did carry a lifted id would now be read the same way as the inline one, which is the stated intent (ADR-0130 D4, one shape per door).

  5. Edge the old guard carried. A body with a nested plain-object manifest that has neither id nor name was left nested before and is flattened now. No sender builds it; the result is then judged by the id gate (1b) as any flat body is. Acceptable and consistent with the unmanaged-import flatten.

  6. Controls hold. ^18 lifted body: 200, [protocol] warnings [] (the false "declares no engines.protocol range" WARN is gone — a correctness gain, not a contract change: the warning was wrong). No-engines lifted body: 200 with exactly one [protocol] warning naming the id, as before. ^17 lifted body: expectProtocolRefusal (422, code, message, five details fields), expectNothingWritten (no register, no sync, no ledger file), no warning. Dependency: 422 DEPENDENCY_MISSING naming com.example.sales/6.x/absent, nothing written; installs once the dependency is present in range.

  7. Envelope identity. JSON.stringify(viaCli) === JSON.stringify(viaFlat) pins the lifted body against the flat manifest. Parity with POST /packages transits: the existing the two package-install doors give the same answer group pins the flat manifest against /packages, and the lifted body reaches step 1c as that same flat object; the one assertProtocolCompat and the one protocolIncompatibleAnswer helper are untouched. No second handshake, as the ruling requires.

  8. Nothing ledgered on refusal. Both refusals sit at 1c/1c', ahead of the hot-register (step 3) and the ledger write (L2018); expectNothingWritten covers register, sync and the storage dir in the new cases.

  9. First-party callers and what they now get. CLI os package install ./dist/objectstack.json (install.ts L214): a mismatched artifact now exits 1 with the 422 the CLI already renders (measured transcript in the PR); compatible ones install with no false WARN. CLI untouched, correct: the lift is harmless under this door and the door owns the shape. objectui Console (marketplaceApi.ts L860-866) posts { packageId, versionId?, installMode? } — the catalog branch, no inline manifest — unchanged. Six dogfood tests build the lifted body (install-local-listing-not-loaded, -listing-sample-data, -no-active-organization, -purge-sample-data, -sample-data-not-loaded, -connector-spec-file); all install compatible artifacts, and their ledger entries are now stored flat. listing-not-loaded edits crm.manifest.engines at the top of the stored entry, which is where the flat entry keeps it, so it still pins what it pinned. The dev's local dogfood battery on this head: 364 files / 2934 tests passed; the CI dogfood shards are skipped on pull_request events by design (ci.yml L2391), so that local run is the evidence for the dogfood face.

  10. install-local: the boot rehydrate judges a ledger entry in the CLI's lifted shape unflattened, so a pre-fix install with an excluded engines.protocol re-registers unchecked (#28535) #28604 (boot rehydrate reads stored lifted entries unflattened). Out of scope here and not made worse: the rehydrate (L997) and the purge/reseed judge (L3569) read entry.manifest as stored, untouched by this diff. Entries this door writes from now on are flat (pinned by the ledger assertion in the new lit control), so the rehydrate reads them correctly; only entries written by a pre-fix install keep the lifted shape, which is exactly install-local: the boot rehydrate judges a ledger entry in the CLI's lifted shape unflattened, so a pre-fix install with an excluded engines.protocol re-registers unchecked (#28535) #28604's reach.

  11. Overlap. This PR's only hunk on the plugin is @@ -1444,13 +1444,19 @@. PR fix(service-automation,objectql,cloud-connection)!: an installed package's connector entry never reads the host's files #28587's plugin hunks are at L650, L1018, L1947; PR feat(metadata-protocol,cli,cloud-connection)!: the override write gate — only the base's paired .custom declares an override (ADR-0146 D2) #28554's at L233, L511, L1924, L2102, L3020 (insertions beside, not inside, the flatten helpers at L520-549). No textual overlap with either; flattenPackageBody itself is not edited by any of the three. PR fix(cloud-connection)!: an install-local uninstall the registry would refuse is refused 409 before the ledger write, naming the pair or the extender #28571's handleUninstall hunks are untouched.

② Semver level

@objectstack/cloud-connection: patch. AGENTS.md step 3: (narrowing) is BREAKING; a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition. The changeset carries both declaration lines (Clause-②: no (narrowing), Metadata-protocol: none), the **BREAKING** banner, and exactly one marker adr-0087: not-required (no-migration-prescription); its body prescribes no consumer rewrite (nothing an author writes is added, removed or re-shaped — the refusals are the existing shared answers), so the category's one mechanical check holds. Metadata-protocol: none is right: no key, value or meaning an author writes changed. Title carries ! and matches the changeset's first line. The Check Changeset job (which runs check-adr-0087-registration.mjs against the merge base) is green on this head. Level and declarations consistent.

③ Boundary flags

④ Design judgment

Narrowing face, no hits: the narrowing judged against the published contract on main. ADR-0087 D1 says the handshake runs before a package's metadata is loaded, and skills/objectstack-platform/SKILL.md and content/docs/protocol/kernel/plugin-spec.mdx already state that every install door refuses an out-of-range app and runs the pre-install dependency check; no page described the CLI artifact path as an exception. So this change takes no declared capability away from a consumer — it makes the spec's own refusal real at the one sender that escaped it. The fix is at the right layer: the door reads the shape it receives, not the sender's identity; the old guard was a sender-shaped exception, and removing it leaves one flatten (flattenPackageBody) serving the inline branch, the catalog branch and the unmanaged import, with declaredPackageManifest now an identity read on both branches (left in place, still stating where each shape keeps its keys — acceptable). Against the four axes: a real deployment path (the air-gapped install an administrator uses without a catalog); contract-first (one handshake, one dependency check, one flatten, no ?? fallback); it makes the wrong thing loud (an AI-built artifact for another protocol is refused at the boundary instead of loading half-working under a false warning); and it adds no new gate or surface. The tests build the body exactly as install.ts builds it, which is the scenario the card accepts against. Holds.

Implemented-by: claude/project-thread-0kdxhp
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-01T07:56Z · head 8454de72 · #28535

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with no assignees, Fixes #28535, and a patch changeset for @objectstack/cloud-connection with its ADR-0087 marker. The title carries !.
    • check-governed-merges --pr 28603 reads NOT governed, 133 changed lines. Landing tier S.
    • The claim (6554580917) names this PR's branch, claude/project-thread-0kdxhp.
  2. Against the order:
    • The dev measured with the real CLI binary first. On main, a ^17 or ^19 artifact installed with exit 0 and a false "declares no engines.protocol range" WARN.
    • The door owns the contract. normalizeBundle now flattens any body with a nested plain-object manifest through the existing flattenPackageBody, so the CLI's lifted body reaches the one assertProtocolCompat. An excluded range answers 422 OS_PROTOCOL_INCOMPATIBLE, and unmet dependencies answer 422 DEPENDENCY_MISSING. Nothing is ledgered on refusal.
    • There is no second handshake, and the CLI is untouched.
    • A flat manifest and the bare compiled artifact are unchanged. A ^18 artifact installs with no WARN, and an artifact with no engines keeps its one WARN.
    • Clause-②: no (narrowing), Metadata-protocol: none.
  3. Contract review: owed for the narrowing. PASS at CONTRACT_REVIEW_TIER on this head, comment 6555165519. On a conflict, the top-level key wins, which is the same order as the unmanaged import, so the two readers now agree.
  4. Gates: CI on head 8454de72 shows 35 success, 13 skipped and 0 red. The state is clean.
  5. Overlap: no conflict against main d94dd3a6. The review confirmed there is no hunk overlap with open PRs fix(service-automation,objectql,cloud-connection)!: an installed package's connector entry never reads the host's files #28587 and feat(metadata-protocol,cli,cloud-connection)!: the override write gate — only the base's paired .custom declares an override (ADR-0146 D2) #28554.
  6. Follow-up: install-local: the boot rehydrate judges a ledger entry in the CLI's lifted shape unflattened, so a pre-fix install with an excluded engines.protocol re-registers unchecked (#28535) #28604. The boot rehydrate judges a ledger entry written by a pre-fix install in the lifted shape unflattened. It is out of scope here and not made worse.
  7. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant