Skip to content

Commit 75ddcd1

Browse files
fix(cloud-connection): install-local runs the ADR-0087 D1 protocol handshake and refuses with the packages door answer (422) (#21805)
Fixes #21762 Clause-②: yes (widening) ## What changes `POST /api/v1/marketplace/install-local` now runs ADR-0087 D1's protocol handshake, and its `kernel:ready` rehydrate does too. On `origin/main` both loaded a package built for another protocol major. - **Install door** (`packages/cloud-connection/src/marketplace-install-local-plugin.ts`, step 1c). The route calls `assertProtocolCompat` right after the manifest id is parsed. That is before the unrunnable-code judgement, the collision check, the posture gate, `manifest.register`, the ledger write and `syncSchemas`. A refusal answers `422 OS_PROTOCOL_INCOMPATIBLE` with the handshake's message and `error.details: { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }`. The answer is the same on the inline-manifest branch and the cloud-snapshot branch. A missing or unreadable range is still admitted, and the handshake's warning goes to `ctx.logger.warn`. - **Rehydrate.** On `kernel:ready`, `checkProtocolCompat` judges each ledger entry before `register`. An `incompatible` entry is not loaded: nothing is registered, synced, bound or seeded for it. One `error` line names the code, the package, the handshake's message (which ends with the replay command) and the two remedies (install a compatible version, or DELETE). The boot continues, and the entry stays in the ledger. A missing or unreadable range rehydrates as before, with no new warning. - **One shared answer** (`packages/metadata-core/src/protocol-handshake.ts`). `protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswer` returns `{ status, code, message, details }`. `details` is a closed shape with the five members named one by one. It sits beside `ProtocolIncompatibleError` and `isProtocolIncompatibleError`. The packages door's module-private `protocolIncompatibleAnswer(deps, err)` is deleted, and `packages/runtime/src/domains/packages.ts` now calls the shared helper. Both doors recognise the error with the shared brand predicate and shape it with the shared helper, so no second copy is left. - **Dependency edge.** `@objectstack/cloud-connection` now imports `@objectstack/metadata-core` from production source, so the package moves from `devDependencies` to `dependencies` (`pnpm-lock.yaml`: only that importer hunk). No new package enters the install closure, because `runtime` and `core` already depend on it. ## Rulings applied (triage 5982323247 and its unlock 5986276908) - The install route calls the same `assertProtocolCompat` before anything is registered, written or synced. - The refusal answers through the same carrier as the packages door: 422, with the structured diagnostic in `details`. Shared, not copied. - The recogniser is one helper both doors call. It sits beside `ProtocolIncompatibleError` in `metadata-core`, the measured common ancestor of `runtime` and `cloud-connection` (see H4). - The rehydrate was measured and then pinned. On BASE it **loaded** the incompatible entry. Now it does not, logs loudly, and the boot continues. This is the behaviour the ruling expected. ## Mechanism assumptions, measured at BASE `e27a7c0c9e` - **H1, confirmed.** `git grep -E "assertProtocolCompat|checkProtocolCompat|OS_PROTOCOL_INCOMPATIBLE"` over the plugin: 0 hits. The same grep hits `runtime/src/domains/packages.ts` and `metadata-core/src/protocol-handshake.ts`. - **H2, confirmed.** `metadata-core`'s `.` entry has `export * from './protocol-handshake.js'`, which already exported `assertProtocolCompat`, `checkProtocolCompat`, `isProtocolIncompatibleError` and `ProtocolIncompatibleError`. - **H3, confirmed.** `protocolIncompatibleAnswer(deps, err)` at `packages.ts:607` was module-private and took `DomainHandlerDeps`. - **H4, confirmed.** `cloud-connection` listed `metadata-core` only in `devDependencies`. Its `vitest.config.ts` already aliases `@objectstack/metadata-core` to source, so the `check:test-source-alias` ledger does not move. Neither `runtime` nor `core` re-exports `metadata-core`, so a direct edge is the only import path. That path does not cross a layering gate: `check:lean-entry-closure` guards only `@objectstack/objectql/core`. `check:undeclared-dep-imports` is green with the edge. - **H5, measured.** For the card's manifest, the two doors give byte-identical `status`, `error.code`, `error.message` and `error.details`. The parity case below asserts this. The envelopes differ in one member: the dispatcher's builder adds `error.httpStatus` to every error it emits, and install-local's hand-built bodies have never carried it on any exit. Both parse as `ApiErrorSchema`. See the acceptance notes. - **H6, measured with a throwaway probe on BASE** (the plugin through `start` + `kernel:ready`, with a ledger entry `engines.protocol: ^16`). Registered ids: `[marketplace-installed-ui, com.example.qaold]`. `syncSchemas` calls: 1. `logger.error`: []. `logger.warn`: []. `logger.info`: `rehydrated com.example.qaold@1.0.0`. The install reproduced the card: 200, registered, ledger file written, 1 sync. ## Clause-② reading: built declaration closure, before and after The dist of `metadata-core`, `runtime` and `cloud-connection` was built at BASE and again at HEAD. The TypeScript checker walked each entry: the exported names, plus every declaration reachable through members, parameters, return types and heritage. - **`@objectstack/metadata-core` `.`: grows by exactly two names.** There are 169 exports before and 171 after. The two new ones are `interface ProtocolIncompatibleAnswer { status: ProtocolIncompatibleError['status']; code: ProtocolIncompatibleError['code']; message: string; details: Pick` of `ProtocolIncompatibleDiagnostic` over `'requiredRange' | 'rangeSource' | 'protocolVersion' | 'targetMajor' | 'migrateCommand'` `}` and `declare function protocolIncompatibleAnswer(err: ProtocolIncompatibleError): ProtocolIncompatibleAnswer`. Every type reachable through them (`ProtocolIncompatibleError`, `ProtocolIncompatibleDiagnostic`, `RangeSource`) was already exported, and its declaration text is unchanged. The 24 external references are identical. Five `SysMetadata*Object` declarations hash differently only because the emitted field-type union prints its members in a different order (`"user" | "code"` becomes `"code" | "user"`). The member set is the same. - **`@objectstack/metadata-core` `./testing`:** the closure is identical. - **`@objectstack/runtime` `.`:** `index.d.ts` and `index.d.cts` are byte-identical before and after (sha256 prefix `cb442723451fa416`). The 513 exports are unchanged. - **`@objectstack/cloud-connection` `.`:** the 40 exports are unchanged. `MarketplaceInstallLocalPlugin`'s declaration gains one untyped `private reportProtocolIncompatibleEntry;` and doc text. The class already had private members, so its assignability does not move. - **Verdict:** `yes (widening)` holds, for `metadata-core` only. The door's accept set narrows, but it narrows back to a declared contract: ADR-0087 D1 checks "the package installer", and `POST /api/v1/packages` already refused the same manifest. I read that as outside Clause-② (`execution-duties.md`: 条款②只指已发布契约面,拉回已声明契约不触它). The seat should confirm this; I did not take the `(narrowing)` arm. - **Changeset levels:** `metadata-core` minor, `cloud-connection` patch, `runtime` patch. All three are in the fixed group. `metadata-core` `.` exports after this change (order-insensitive; the two new names are `ProtocolIncompatibleAnswer` and `protocolIncompatibleAnswer`): ```text AUDIT_FIELD_DEFS, AUDIT_FIELD_GOVERNANCE, AnonymousFormIntakeCandidate, AnonymousFormIntakeUnavailable, ArtifactConversionNotice, ArtifactForwardConversionOptions, ArtifactForwardConversionResult, ArtifactForwardConversionVerdict, ArtifactReplayedRetirement, BOUND_FORM_FIELD_PREDICATE_ROOTS, BOUND_FORM_VIEW_PREDICATE_ROOTS, BranchError, CacheStats, ConflictError, DeleteOptions, DeleteResult, ENGINE_DELETE_DISPATCH_CASES, ENGINE_DELETE_REJECT_MESSAGE, ENGINE_FINDONE_PREDICATE_CASES, ENGINE_UPDATE_DISPATCH_CASES, ENGINE_UPDATE_ID_CONFLICT_CODE, ENGINE_UPDATE_ID_CONFLICT_STATUS, ENGINE_UPDATE_REJECT_MESSAGE, EngineDeleteDispatch, EngineDeleteDispatchCase, EngineDeleteDispatchInput, EngineFindOnePredicate, EngineFindOnePredicateCase, EngineFindOneQueryInput, EngineUpdateDispatch, EngineUpdateDispatchCase, EngineUpdateDispatchData, EngineUpdateDispatchInput, FormPredicateSurface, HistoryOptions, ITEM_KEY_DISCRIMINATORS, InMemoryRepository, InMemoryRepositoryOptions, InjectedColumnProvenance, LAYER_SOURCE, LayerConfig, LayeredRepository, LayeredRepositoryOptions, ListFilter, METADATA_AUTHORING_CAPABILITY, MetaRef, MetaRefSchema, MetaWriteCapabilityVerdict, MetaWriteOperation, MetadataCache, MetadataCacheOptions, MetadataError, MetadataEvent, MetadataEventSchema, MetadataItem, MetadataItemHeader, MetadataItemSchema, MetadataOp, MetadataOpSchema, MetadataRepository, MetadataType, MetadataTypeSchema, MetadataWriteIntent, NotFoundError, OBJECT_FIELD_TYPE_REFUSED_ERROR_NAME, OBJECT_SCHEMA_MASK_DISABLE_ENV, OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES, OBJECT_SCHEMA_MASK_NOT_APPLICABLE, OBJECT_SCHEMA_MASK_UNDETERMINED_METRIC, OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, OBJECT_SCHEMA_WRITE_CAPABILITIES, ORG_PRESENTATION_AUTHORING_CAPABILITY, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, ObjectFieldTypeRefusal, ObjectFieldTypeViolation, ObjectSchemaMaskEvaluationError, ObjectSchemaMaskPassthroughReason, ObjectSchemaMaskPosture, ObjectSchemaMaskResult, ObjectSchemaMaskSecuritySurface, ObjectSchemaMaskTelemetry, ProtocolCompatResult, ProtocolHandshakeManifest, ProtocolIncompatibleAnswer, ProtocolIncompatibleDiagnostic, ProtocolIncompatibleError, PutOptions, PutResult, RangeSource, RecordOrganizationResolver, SchemaValidationError, SysMetadata, SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject, SysViewDefinitionObject, TENANT_SCOPE_FIELD_DEF, UnboundFormPredicateRoot, WarnFn, WatchFilter, anonymousFormIntakeCandidates, anonymousFormIntakePosture, anonymousFormIntakeSlug, anonymousFormIntakeSlugs, anonymousFormIntakeUnavailability, anonymousFormIntakeUnavailableMessage, anonymousFormIntakeUnavailableRemedy, anonymousFormObjectName, anonymousFormSharingPath, applyArtifactForwardConversions, applyAuditFieldGovernance, applyInjectedSystemColumns, applyObjectSchemaMask, assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch, assertProtocolCompat, canonicalize, checkProtocolCompat, createFieldPresenceProbe, createRecordOrganizationResolver, createRecordWallOrganizationResolver, declaresOrgOverride, describeUndeclarableFieldType, detectUnboundFormViewPredicateRoots, engineByIdUnhonouredPredicateMessage, engineFindOnePredicateRefusalMessage, engineUpdateDispatchRejectError, engineUpdateIdConflictMessage, engineUpdateIdPredicateConflictMessage, findUndeclarableFieldType, foldVisibilityFingerprintIntoEtag, hashSpec, injectedSystemColumnDefs, isCodeArtifactBody, isDeclarableFieldType, isObjectFieldTypeRefused, isObjectSchemaMaskExempt, isObjectSchemaMaskingEnabled, isProtocolIncompatibleError, isTenantAuthored, itemDiscriminator, metaWriteCapabilityVerdict, normalizeIfNoneMatch, objectFieldVisibilityFingerprint, organizationIdForMetaRead, organizationIdForMetaWrite, parseRangeFloor, platformProvisionsStorage, protocolIncompatibleAnswer, publicFormSlug, rangeAdmitsMajor, readDiscriminatorValue, refKey, resolveDeclaredRange, resolveEngineDeleteDispatch, resolveEngineFindOnePredicate, resolveEngineUpdateDispatch, resolveInjectedColumnProvenance, resolveInstalledSpecVersion, resolveObjectSchemaMaskPosture, resolveRecordOrganizationField, resolveRecordWallOrganizationField, scalarDeleteId, scalarUpdateId, stripInjectedSystemColumns, unboundRootsInCelSource, unhonouredByIdPredicateKeys, unprovisionedInjectedColumns ``` ## Tests (HEAD `6ca235b9`) - `marketplace-install-local-protocol-handshake.test.ts` (new, 11 pins): - The inline-manifest and cloud-snapshot refusals: 422, declared envelope (`BaseResponseSchema`, `envelopeViolations`, `ApiErrorSchema`), `OS_PROTOCOL_INCOMPATIBLE`, exactly the five `details`. Nothing registered, no ledger file, 0 syncs. - The range is judged before the package's code, with a control: the same handler-only job under `^17` answers `VALIDATION_ERROR`. - A refused upgrade leaves the installed ledger file byte-identical. - `^17` control: 200, registered, written, synced. - No-range control: 200, with one `[protocol]` warning on the plugin logger. - **Parity:** `POST /api/v1/packages`, driven through the runtime's real `HttpDispatcher`, and install-local give byte-equal `{status, code, message, details}`. - **Rehydrate:** the incompatible entry is not registered, has 0 syncs and produces one `error` line naming the code, the id and `objectstack migrate meta --from 16`. The boot continues (a compatible entry rehydrates and the routes mount). DELETE still removes the entry, and a `^17` version replaces it. - `protocol-handshake.test.ts` (+3): the helper's status, code and message; exactly five `details` members valued from the diagnostic; closed shape (a member added to the diagnostic does not leak). - `packages-install-protocol-incompatible.test.ts`: the header's "only HTTP door" claim is updated. Its 6 pins still pass unchanged, so the packages door's wire is the same after the switch to the helper. - Full suites: `metadata-core` 18 files / 374 passed. `cloud-connection` 39 / 477 passed. `runtime` 325 / 4624 passed (19 skipped). `typecheck` exits 0 for all three, and `--listFiles` confirms both new test files are in their programs. ## Ablations (`scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty) - **A: install handshake call replaced with a no-op.** Anchor 1 → 0, blob `2ce1f2e4` → `e1c43b2a`. 6 of 11 went red. The inline refusal read `expected 200 to be 422`, the card's defect. The ordering pin read `VALIDATION_ERROR`, the no-range warning count read 0, and parity failed. The 5 rehydrate and control pins stayed green. - **B: rehydrate refusal disabled.** 2 of 11 went red: the not-loaded pin (`expected [ …(2) ] to not include 'com.example.qaold'`) and boot-continues. The DELETE and replace preservation pins stayed green. - **C: helper's `details` turned into a spread of the diagnostic.** My first attempt was a **no-op**: the replacement contained the anchor, the tool counted it 1 → 1 and refused, and nothing ran. Re-anchored, the mutation landed (blob `062e9469` → `7a0a3d18`): 2 of 25 went red in `metadata-core` (exact members, closed shape) and 5 of 11 in `cloud-connection`. Parity went red too, because install-local read the aliased mutated source while the packages door read the built dist. - No build sat between mutation and run: each subject is imported relatively from `src`, or through the existing `metadata-core` source alias. ## Gates (HEAD `6ca235b9`) - `node scripts/pm/dispatch-gates.mjs --commands` derived 74 commands from this change set. All 74 ran, and `--ran` reports: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN. - Two of them first exited 3 (prerequisite, not red). `check-plugin-teardown-shape --self-test` needed its pinned positive-control commit in this shallow clone; after fetching it, 48 cases passed. `check:dual-build-cjs-loads` needed every package's `dist/`; after `pnpm build` (72/72 tasks), it exited 0. - `pnpm lint` (the full `eslint . --no-inline-config`) exited 0 with no findings. - `origin/main` has moved to `d13df0c6` (3 commits). None of them touches `metadata-core`, `runtime`, `cloud-connection` or `pnpm-lock.yaml`, so I did not merge. ## Acceptance notes - `packages/spec/src/api/error-code-ledger.zod.ts`: the `OS_PROTOCOL_INCOMPATIBLE` row's comment still says "The one HTTP door that reaches the throw, `POST /api/v1/packages`". There are two doors now. That file belongs to the `domain:spec` seat, so this PR does not edit it. - `packages/runtime/src/app-plugin.test.ts` (the 422 boot-seam case): its comment calls `AppPlugin` "the one other caller of `assertProtocolCompat`". There are three callers now. This is comment drift and is not edited here. - The `GET` install-local listing still serves an entry the rehydrate refused, because it reads the ledger. Only the `error` log says the entry is not loaded. This matches the posture this door already keeps for an unreadable ledger entry (log, wire unchanged). - Reseed and purge on a refused entry were not measured. - The handshake judges the top-level manifest only. A multi-package artifact's per-package ranges were not measured at this door, and `POST /api/v1/packages` and `AppPlugin` judge the same scope. - Envelope dialect: install-local's hand-built errors carry no `error.httpStatus`, and the dispatcher's carry it on every exit. This predates this PR and is door-wide. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18c2ddc commit 75ddcd1

9 files changed

Lines changed: 596 additions & 52 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/cloud-connection': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
`POST /api/v1/marketplace/install-local` now runs the ADR-0087 D1 protocol handshake. A manifest whose declared range excludes this runtime's protocol major is refused with `422 OS_PROTOCOL_INCOMPATIBLE`, the answer `POST /api/v1/packages` already gives. It used to install with a `200` (#21762).
8+
9+
Clause-②: yes (widening)
10+
11+
- **Install.** The handshake runs after the manifest id is parsed and before anything is registered, written or synced. The range is read from `engines.protocol`, then `engines.platform`, then `engine.objectstack`. The refusal answers `422` with `error.code: 'OS_PROTOCOL_INCOMPATIBLE'`, the handshake's own `error.message`, and `error.details: { requiredRange, rangeSource, protocolVersion, targetMajor, migrateCommand }`. It is the same on the inline-manifest branch and the cloud-snapshot branch. No ledger file is written, and an installed earlier version stays as it was. A manifest with no range, or a range the handshake cannot read, still installs, and the handshake's warning goes to the plugin's logger.
12+
- **Restart.** On `kernel:ready`, a ledger entry whose range excludes this runtime's major is not loaded. Nothing is registered, synced, bound or seeded for it. One `error` line names the package, `OS_PROTOCOL_INCOMPATIBLE` and the replay command (`objectstack migrate meta --from N`). The boot continues with the other entries. The entry stays in the ledger, so `DELETE /api/v1/marketplace/install-local/{id}` still removes it, and installing a compatible version replaces it. Before, it was registered and its schemas synced, with no warning.
13+
- **`@objectstack/metadata-core`:** a new export, `protocolIncompatibleAnswer(err)`, with its return type `ProtocolIncompatibleAnswer`. It turns a `ProtocolIncompatibleError` into the status, code, message and five-member `details` an HTTP door answers. Both install doors call it, so their answers are the same bytes.
14+
- **`@objectstack/runtime`:** `POST /api/v1/packages` answers through that helper. Its response is unchanged.
15+
16+
A client that relied on install-local accepting a package built for another protocol major gets `422` now. Install a version built for this runtime's protocol, or migrate the package with the `migrateCommand` in the refusal.

‎packages/cloud-connection/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@
2525
},
2626
"dependencies": {
2727
"@objectstack/core": "workspace:*",
28+
"@objectstack/metadata-core": "workspace:*",
2829
"@objectstack/metadata-protocol": "workspace:*",
2930
"@objectstack/runtime": "workspace:*",
3031
"@objectstack/spec": "workspace:*",
3132
"@objectstack/types": "workspace:*"
3233
},
3334
"devDependencies": {
3435
"@objectstack/lint": "workspace:*",
35-
"@objectstack/metadata-core": "workspace:*",
3636
"@types/node": "^26.6.3",
3737
"typescript": "^6.0.3",
3838
"vitest": "^4.1.11"

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
* no JSON door carries. The package's job bodies are scheduled on
3535
* install and on every rehydrate; on a rehydrate, a hook with no
3636
* `body` that an older build installed is warned and NOT bound.
37+
* A manifest whose `engines.protocol` excludes this runtime's major
38+
* answers `OS_PROTOCOL_INCOMPATIBLE` (422) with the handshake's
39+
* diagnostic in `error.details`, the answer `POST /api/v1/packages`
40+
* gives (ADR-0087 D1, #21762), and nothing is registered or written.
3741
*
3842
* GET /api/v1/marketplace/install-local
3943
* → lists currently installed marketplace packages. Requires an
@@ -57,7 +61,9 @@
5761
*
5862
* On `kernel:ready`, the plugin scans the directory and re-registers each
5963
* cached manifest so installs survive process restarts without further
60-
* cloud round-trips.
64+
* cloud round-trips. An entry whose `engines.protocol` excludes this runtime's
65+
* major is not loaded: it is reported at `error`, naming the replay command,
66+
* and the boot continues (ADR-0087 D1, #21762).
6167
*/
6268

6369
import type { Plugin, PluginContext } from '@objectstack/core';
@@ -85,6 +91,17 @@ import {
8591
} from '@objectstack/types';
8692
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
8793
import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel';
94+
// [#21762] ADR-0087 D1's protocol handshake, its brand predicate and the ONE
95+
// answer both package-install doors give its refusal. Imported from the
96+
// producer, never restated: `POST /api/v1/packages` answers through the same
97+
// helper, so the two doors cannot drift apart on the wire.
98+
import {
99+
assertProtocolCompat,
100+
checkProtocolCompat,
101+
isProtocolIncompatibleError,
102+
protocolIncompatibleAnswer,
103+
type ProtocolIncompatibleDiagnostic,
104+
} from '@objectstack/metadata-core';
88105
import { resolveCloudUrl } from './cloud-url.js';
89106
import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js';
90107
import { join } from 'node:path';
@@ -485,6 +502,23 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
485502

486503
for (const entry of entries) {
487504
try {
505+
// [#21762] ADR-0087 D1: the handshake runs "before loading a
506+
// package's metadata", and a rehydrate IS a load. An entry whose
507+
// declared range excludes this runtime's major (installed before
508+
// the install door checked, or by a runtime of another protocol
509+
// sharing this ledger) is NOT loaded: nothing is registered,
510+
// synced, bound or seeded for it, and the boot continues with the
511+
// rest. Its ledger entry is kept, so DELETE can still remove it
512+
// and a compatible version can replace it.
513+
//
514+
// `checkProtocolCompat` is the handshake's own judge. Only a
515+
// positive incompatibility is acted on: an absent or
516+
// unrecognised range rehydrates exactly as it did before.
517+
const compat = checkProtocolCompat(entry.manifest);
518+
if (compat.status === 'incompatible') {
519+
this.reportProtocolIncompatibleEntry(ctx, entry, compat.diagnostic);
520+
continue;
521+
}
488522
// Awaited: register also bridges the manifest's objects into
489523
// the metadata service (late-registration bridge in
490524
// ObjectQLPlugin) — wait for that so metadata consumers see
@@ -1011,7 +1045,38 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
10111045
const manifestId = declaredId.data;
10121046
if (inlineManifest) packageId = manifestId;
10131047

1014-
// 1c. [#21489] ⭐ CODE THIS DOOR CANNOT RUN IS REFUSED, not installed.
1048+
// 1c. [#21762] ⭐ ADR-0087 D1'S PROTOCOL HANDSHAKE, before anything is
1049+
// registered, written or synced: the same `assertProtocolCompat`
1050+
// the other package-install door (`POST /api/v1/packages`) and the
1051+
// protocol install primitive run. Before this, a manifest whose
1052+
// `engines.protocol` excludes this runtime's major answered 200 here:
1053+
// registered, its ledger entry written, its schemas synced, while
1054+
// the other door refused the same manifest with a 422.
1055+
//
1056+
// The refusal is that door's answer, from the one shared helper:
1057+
// 422 `OS_PROTOCOL_INCOMPATIBLE`, the error's own message, and the
1058+
// diagnostic's five fields in `error.details`. It is 422 on BOTH
1059+
// branches: a package built for another protocol is a body this
1060+
// runtime cannot load, not an upstream fault, whichever branch
1061+
// supplied it (the unrunnable-code refusal below reasons the same).
1062+
//
1063+
// After the id gate, so the diagnostic names a parsed id. Ahead of
1064+
// the unrunnable-code judgement, which reads the package's jobs and
1065+
// hooks with THIS runtime's binder: ADR-0087 D1 checks "before
1066+
// loading a package's metadata". An absent or unrecognised range is
1067+
// admitted with a warning, as at every seam the handshake guards.
1068+
try {
1069+
assertProtocolCompat(manifest, undefined, (m) => ctx.logger?.warn?.(`[MarketplaceInstallLocal] ${m}`));
1070+
} catch (err) {
1071+
if (!isProtocolIncompatibleError(err)) throw err;
1072+
const refusal = protocolIncompatibleAnswer(err);
1073+
return c.json({
1074+
success: false,
1075+
error: { code: refusal.code, message: refusal.message, details: refusal.details },
1076+
}, refusal.status);
1077+
}
1078+
1079+
// 1d. [#21489] ⭐ CODE THIS DOOR CANNOT RUN IS REFUSED, not installed.
10151080
// A job runs on a JSON door only through its sandboxed `body`; its
10161081
// deprecated `handler` names a `defineStack({ functions })` entry,
10171082
// which is code and travels only in the artifact's runtime module —
@@ -2542,6 +2607,29 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
25422607
* they are the two things that turn "an app is missing" into a fix:
25432608
* `.objectstack/installed-packages/<file>` is the thing to repair or delete.
25442609
*/
2610+
/**
2611+
* [#21762] The one line a rehydrate prints for a ledger entry it refuses to
2612+
* load under ADR-0087 D1's handshake.
2613+
*
2614+
* `error`, not `warn`: the ledger says the package is installed (the GET
2615+
* listing still serves it) while the running kernel holds none of it, so
2616+
* persisted and runtime state disagree with nothing else saying so. The
2617+
* line owes the consequence and the fix, and carries the handshake's own
2618+
* message, which names the replay command (`objectstack migrate meta`).
2619+
*/
2620+
private reportProtocolIncompatibleEntry = (
2621+
ctx: PluginContext,
2622+
entry: InstalledEntry,
2623+
diagnostic: ProtocolIncompatibleDiagnostic,
2624+
): void => {
2625+
ctx.logger?.error?.(
2626+
`[MarketplaceInstallLocal] ${diagnostic.code}: ${entry.manifestId}@${entry.version} is NOT loaded into `
2627+
+ `this runtime, though its ledger entry lists it as installed — none of its objects, data or handlers `
2628+
+ `are available: ${diagnostic.message}. Install a version of the package built for protocol `
2629+
+ `${diagnostic.protocolVersion} (POST ${ROUTE_BASE}), or remove it (DELETE ${ROUTE_BASE}/${entry.manifestId}).`,
2630+
);
2631+
};
2632+
25452633
private warnSkippedLedgerEntries = (ctx: PluginContext, skipped: SkippedManifestEntry[], what: string): void => {
25462634
for (const { file, cause } of skipped) {
25472635
ctx.logger?.warn?.(

0 commit comments

Comments
 (0)