Repository navigation
Commit 75ddcd1
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
File tree
- .changeset
- packages
- cloud-connection
- src
- metadata-core/src
- runtime/src/domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
33 | 34 | | |
34 | 35 | | |
35 | | - | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| |||
Lines changed: 90 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
34 | 34 | | |
35 | 35 | | |
36 | 36 | | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
37 | 41 | | |
38 | 42 | | |
39 | 43 | | |
| |||
57 | 61 | | |
58 | 62 | | |
59 | 63 | | |
60 | | - | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
61 | 67 | | |
62 | 68 | | |
63 | 69 | | |
| |||
85 | 91 | | |
86 | 92 | | |
87 | 93 | | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
88 | 105 | | |
89 | 106 | | |
90 | 107 | | |
| |||
485 | 502 | | |
486 | 503 | | |
487 | 504 | | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
488 | 522 | | |
489 | 523 | | |
490 | 524 | | |
| |||
1011 | 1045 | | |
1012 | 1046 | | |
1013 | 1047 | | |
1014 | | - | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
| 1057 | + | |
| 1058 | + | |
| 1059 | + | |
| 1060 | + | |
| 1061 | + | |
| 1062 | + | |
| 1063 | + | |
| 1064 | + | |
| 1065 | + | |
| 1066 | + | |
| 1067 | + | |
| 1068 | + | |
| 1069 | + | |
| 1070 | + | |
| 1071 | + | |
| 1072 | + | |
| 1073 | + | |
| 1074 | + | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
1015 | 1080 | | |
1016 | 1081 | | |
1017 | 1082 | | |
| |||
2542 | 2607 | | |
2543 | 2608 | | |
2544 | 2609 | | |
| 2610 | + | |
| 2611 | + | |
| 2612 | + | |
| 2613 | + | |
| 2614 | + | |
| 2615 | + | |
| 2616 | + | |
| 2617 | + | |
| 2618 | + | |
| 2619 | + | |
| 2620 | + | |
| 2621 | + | |
| 2622 | + | |
| 2623 | + | |
| 2624 | + | |
| 2625 | + | |
| 2626 | + | |
| 2627 | + | |
| 2628 | + | |
| 2629 | + | |
| 2630 | + | |
| 2631 | + | |
| 2632 | + | |
2545 | 2633 | | |
2546 | 2634 | | |
2547 | 2635 | | |
| |||
0 commit comments