Skip to content

Commit 9f9510f

Browse files
fix(cloud-connection): reseed and purge refuse a protocol-incompatible install-local entry before any side effect (#21862)
Fixes #21834 Clause-②: no ## What changed `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and `POST …/:manifestId/purge-sample-data` now run ADR-0087 D1's handshake (`checkProtocolCompat`) on the ledger entry right after reading it. An entry whose declared range excludes this runtime gets the install route's answer for the same manifest: `422 OS_PROTOCOL_INCOMPATIBLE`, the error's own message, and the diagnostic's five fields in `error.details`. The answer is shaped by the producer's `protocolIncompatibleAnswer`, so there is no second copy of the shaping. Nothing happens before that answer: no translation load, no seed-dataset merge, no seed-row read or delete, and no ledger write. - `packages/cloud-connection/src/marketplace-install-local-plugin.ts`: one private helper, `refuseProtocolIncompatibleEntry`, called by both doors right after `ledger.read`. Admission, the 404 and the unreadable-ledger answer still come first, because there is no manifest to judge before them. Only a positive incompatibility is refused. An absent or unreadable range is admitted as before, with no new warning. - No change to DELETE, the install route, the rehydrate or the listing. No `packages/spec` path. No new error code. - Changeset: `@objectstack/cloud-connection` `patch`, carrying the same `Clause-②: no` line. - `scripts/engine-double-contract.pinned.json`: one generated row recording the new suite's `delete` double, which routes through `assertEngineDeleteDispatch`. The gate prescribes this (`--write`). It is not part of the claimed surface. ## Mechanism assumptions, measured on `origin/main` `5b2d189e` - **H1, confirmed.** Neither `handleReseed` nor `handlePurge` called a handshake. The reseed reached `applySideEffects`, whose step 1 loads translations and step 2 merges seed datasets (and registers the replayer) before the seed run. - **H2, confirmed.** The install route refuses through `assertProtocolCompat` + `protocolIncompatibleAnswer`. `protocolIncompatibleAnswer` takes a `ProtocolIncompatibleError`, and `checkProtocolCompat` returns the diagnostic. The helper builds the producer's own error from that diagnostic (`new ProtocolIncompatibleError(compat.diagnostic)`, the same construction `assertProtocolCompat` throws) and answers through the shared helper. Per the ruling, the check is `checkProtocolCompat`, not `assertProtocolCompat`, so the doors add no grandfathering warning on loadable entries. - **H3, measured: the doors and the rehydrate's record can disagree.** Suppose a protocol-incompatible entry is written to the ledger after this boot's rehydrate, for example by another runtime sharing the ledger. The GET listing then serves it as loaded: `withSampleData: false`, no `notLoaded` marker, and its per-request seed-row `warn`. Both doors answer it `422`, because they judge the entry themselves, as the ruling directs. This was measured with a throwaway probe in the unit harness, which was not committed. The doors' half is pinned (case 5 below). The listing's half is unchanged and noted under Acceptance notes. - **H4, measured.** In the reseed, both the organization wall's refusal (`mode: 'refused'`, 403) and the `skipped` answer (`400 RESEED_SKIPPED`) are decided inside `applySideEffects`, after steps 1 and 2. So the handshake sits before that call. In the purge, the wall check precedes the engine deletes and the ledger write, and the handshake sits before both. Pinned: on a walled boot with no active organization, a refused entry gets `422`, not `403`, and nothing moves (case 4). ## Tests (`92261ad5`) - New unit suite `packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts`: 10 passed. It uses the real plugin `start()` + `kernel:ready` over a pre-written ledger and the real `SeedLoaderService`, over an in-memory engine that answers only for registered objects. Its probes are the `i18n` service's `loadTranslations` call count, the length of the shared `seed-datasets` list, the engine's writes, and the ledger directory's bytes. The cases: 1. Precondition: the rehydrate refused the old entry and loaded the current one. 2. Reseed on the refused entry: `422`, byte-identical to the install route's answer for the same manifest. Every probe unchanged. 3. Purge on the refused entry: the same `422`, no delete, `withSampleData` still `true`. 4. Walled, with no active organization: `422` ahead of the wall's `403`, nothing moves. Control: the loadable entry meets the `403`. 5. An entry written after this boot's rehydrate is refused by both doors. 6. DELETE still works after both refusals. 7. A compatible version installed over the refused entry: reseed `200` (`skipped: 1`), purge `200` (`deleted: 1`). 8–10. Loadable entries answer as before. The reseed moves the probes (+2 translation loads, +1 dataset, ledger rewritten), which is the control for every "unchanged" above. The purge deletes its row and rewrites the ledger. A no-range entry is admitted with no `[protocol]` warning. - New real-boot pin `packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts`: 7 passed. It runs two showcase boots over one database file and one ledger. Boot 1 installs CRM (28 rows), and a reseed there moves the i18n probe. Between the boots, the CRM ledger entry is made to declare the previous major. On boot 2 the rehydrate refuses it. Reseed and purge both answer `422`, byte-identical to the install route's answer for the manifest the ledger holds. The i18n service (same object, 0 loads), the `seed-datasets` length and the ledger bytes are unchanged. A compatible re-install over the entry answers `200`, after which reseed answers `200` (`skipped: 28`, loads equal to boot 1's, +5 datasets) and purge answers `200` (`deleted: 28`). DELETE then removes it. DELETE on a still-refused entry at real boot is pinned by `install-local-listing-not-loaded.dogfood.test.ts`. - `@objectstack/cloud-connection` full suite: 41 files, 505 tests passed (at `9f60b045`; the only later commit is the ledger JSON, which neither package reads). - `pnpm --filter @objectstack/cloud-connection typecheck` and `pnpm --filter @objectstack/dogfood typecheck`: both exit 0. `--listFiles` counts each new test file once in its program (cloud-connection's two programs and dogfood's). ## Ablation (fix committed first; mutation through `scripts/ablation-replace.mjs`, anchor hit 1, blob changed, restore proven blob == HEAD and `git diff HEAD` empty) The mutation makes the helper never refuse. The plugin is read from source in both suites: a relative import in the unit suite, and the dogfood config's `@objectstack/cloud-connection` source alias. So no rebuild was needed for the mutation to reach the subject. The results went red in the expected direction: - Unit: 6 failed, 4 passed. The refusal cases failed and the precondition and loadable cases held. The ablated reseed answered `400 RESEED_SKIPPED "Reseed did not run: seed-error: Object 'qa_old_account' not found"`. - Real boot: 3 failed, 4 passed. This reproduces the card's measurement. Reseed answered `400 RESEED_SKIPPED "…Object 'crm_account' not found"`. Purge answered `200 {deleted: 0, skipped: 0, errors: 28, withSampleData: false}`. The probes moved: `translationLoads` 0 to 2, `seed-datasets` 19 to 24, and the ledger's `withSampleData` true to false and `sampleDataPurged` false to true. ## Gates (`92261ad5`) - `node scripts/pm/dispatch-gates.mjs --commands` re-derived on the final head gave 75 commands. The union with the dispatch order's list (including the full `pnpm lint`) is 76 commands, and all 76 exited 0. `--ran`: "75 derived famil(ies) accounted for — 75 run, 0 NOT-MEASURED (a DERIVED zero …)". - `pnpm lint` (`eslint . --no-inline-config`, whole repo, not narrowed): exit 0. - A first pass at `9f60b045` had three non-zero exits, each resolved before the pass above: - `check:engine-double-contract` needed the generated ledger row (committed in `92261ad5`). - `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: eight unrelated packages had no `dist/`). It was built from the turbo cache and then passed. - `check-comment-mask-corpus` read a throwaway probe file I deleted mid-run, so that reading was void. It is green on the clean tree. ## Acceptance notes - **Listing vs doors on an entry written after boot (H3).** The listing marks only what this boot's rehydrate refused, as its contract (#21822) states, so an incompatible entry another runtime writes later is listed as loaded. The doors refuse it. The listing is out of this card's surface and is left unchanged. Not filed: it is not a breach of the listing's stated contract, and its reach is a second runtime of another protocol major sharing one ledger directory. - **`seed-datasets` merge is append-only.** Every loadable reseed appends the package's datasets again (measured +5 on the real boot over a list that already held them). This is unchanged by this PR. It is an observation without a measured wrong answer, so it is not filed. - **Envelope wrap.** The install route keeps its inline `{ success: false, error: { code, message, details } }` wrap, per the claim's "no change to the install route". The two sample-data doors share one wrap in the new helper. Folding the install route onto the helper is a possible follow-up, and it would not change any answer. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a43d90a commit 9f9510f

5 files changed

Lines changed: 746 additions & 2 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
---
4+
5+
`reseed-sample-data` and `purge-sample-data` on an installed package that this runtime refused to load now answer `422 OS_PROTOCOL_INCOMPATIBLE` before they change anything. Before, both acted on such a package anyway.
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** On a restart, a ledger entry whose `engines.protocol` range excludes this runtime is not loaded: nothing is registered, synced, bound or seeded for it. `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` on that entry loaded the package's translations into the i18n service and merged its seed datasets into the kernel's shared `seed-datasets` list, and then failed with `400 RESEED_SKIPPED` because the package's objects were never registered. `POST …/:manifestId/purge-sample-data` answered `200` with every record counted in `errors`, and set the ledger's `withSampleData` to `false` with no row deleted.
10+
- **What it does now.** Both doors run the protocol check on the ledger entry right after reading it. An entry whose declared range excludes this runtime gets the answer the install route gives the same manifest: `422`, `error.code` `OS_PROTOCOL_INCOMPATIBLE`, the check's own message, and `error.details` with `requiredRange`, `rangeSource`, `protocolVersion`, `targetMajor` and `migrateCommand`. No translation is loaded, no dataset is merged, no seed row is read or deleted, and the ledger is not written. The refusal comes before the organization check too, so a session with no active organization on a walled deployment also gets the `422` for such an entry.
11+
- **Unchanged.** An entry this runtime loads is answered exactly as before. An entry that declares no range, or a range the check cannot read, is admitted as before, with no new warning. `DELETE /api/v1/marketplace/install-local/:manifestId` still removes a refused entry, and installing a compatible version over it makes both doors act on it again.

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@
7070
* cloud round-trips. An entry whose `engines.protocol` excludes this runtime's
7171
* major is not loaded: it is reported at `error`, naming the replay command,
7272
* and the boot continues (ADR-0087 D1, #21762). The GET listing marks it as
73-
* not loaded for as long as that ledger entry stands (#21822).
73+
* not loaded for as long as that ledger entry stands (#21822). The reseed and
74+
* purge doors run the handshake on the entry themselves and refuse such an
75+
* entry with the install route's `OS_PROTOCOL_INCOMPATIBLE` (422) before any
76+
* side effect; DELETE and a compatible re-install still act on it (#21834).
7477
*/
7578

7679
import type { Plugin, PluginContext } from '@objectstack/core';
@@ -101,12 +104,14 @@ import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel';
101104
// [#21762] ADR-0087 D1's protocol handshake, its brand predicate and the ONE
102105
// answer both package-install doors give its refusal. Imported from the
103106
// producer, never restated: `POST /api/v1/packages` answers through the same
104-
// helper, so the two doors cannot drift apart on the wire.
107+
// helper, so the two doors cannot drift apart on the wire. [#21834] The
108+
// sample-data doors answer a refused ledger entry through it too.
105109
import {
106110
assertProtocolCompat,
107111
checkProtocolCompat,
108112
isProtocolIncompatibleError,
109113
protocolIncompatibleAnswer,
114+
ProtocolIncompatibleError,
110115
type ProtocolIncompatibleDiagnostic,
111116
} from '@objectstack/metadata-core';
112117
import { resolveCloudUrl } from './cloud-url.js';
@@ -1937,6 +1942,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
19371942
* ADR-0123 D2 / D4's answer ({@link noActiveOrganizationRefusal}); the
19381943
* reseed's other declines (no seed datasets, no data engine or metadata
19391944
* service, a seed run that threw) stay `400 RESEED_SKIPPED`.
1945+
*
1946+
* [#21834] An entry whose declared protocol range excludes this runtime is
1947+
* refused first, with the install route's `422 OS_PROTOCOL_INCOMPATIBLE`
1948+
* ({@link refuseProtocolIncompatibleEntry}): ahead of the side effects,
1949+
* because both of those answers are decided after them.
19401950
*/
19411951
private handleReseed = async (c: any, ctx: PluginContext): Promise<Response> => {
19421952
const admission = await this.requireInstallCapability(c, ctx, 'Reseeding sample data');
@@ -1955,6 +1965,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
19551965
if (!entry) {
19561966
return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'reseed sample data');
19571967
}
1968+
// [#21834] ADR-0087 D1: the entry is judged before `applySideEffects`
1969+
// loads its translations and merges its seed datasets.
1970+
const incompatible = this.refuseProtocolIncompatibleEntry(c, entry);
1971+
if (incompatible) return incompatible;
19581972

19591973
const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c, door: 'reseed' });
19601974
// [ADR-0123 D2 / D4] A walled session with no active organization: the
@@ -2056,6 +2070,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
20562070
* `skipped` counts seed records no row in scope carries (already deleted);
20572071
* `errors` counts records that could not be purged — refused by the engine,
20582072
* or not identifiable — each reason logged.
2073+
*
2074+
* [#21834] An entry whose declared protocol range excludes this runtime is
2075+
* refused first, with the install route's `422 OS_PROTOCOL_INCOMPATIBLE`
2076+
* ({@link refuseProtocolIncompatibleEntry}): no row is deleted and the
2077+
* ledger's `withSampleData` / `sampleDataPurged` are not rewritten.
20592078
*/
20602079
private handlePurge = async (c: any, ctx: PluginContext): Promise<Response> => {
20612080
const admission = await this.requireInstallCapability(c, ctx, 'Purging sample data');
@@ -2074,6 +2093,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
20742093
if (!entry) {
20752094
return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'purge sample data');
20762095
}
2096+
// [#21834] ADR-0087 D1: the entry is judged before any row is matched,
2097+
// deleted, or the ledger is written.
2098+
const incompatible = this.refuseProtocolIncompatibleEntry(c, entry);
2099+
if (incompatible) return incompatible;
20772100

20782101
const datasets = seedDatasetsOf(entry.manifest);
20792102

@@ -2140,6 +2163,43 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
21402163
}, 200);
21412164
};
21422165

2166+
/**
2167+
* [#21834] ADR-0087 D1's handshake on the two sample-data doors: the answer
2168+
* reseed and purge give a ledger entry whose declared protocol range
2169+
* excludes this runtime, or `undefined` for an entry they may act on.
2170+
*
2171+
* The rehydrate does not load such an entry: nothing is registered, synced,
2172+
* bound or seeded for it. The two doors used to act on it anyway. The reseed
2173+
* loaded its translations into the i18n service and merged its seed datasets
2174+
* into the shared `seed-datasets` list before its seed run failed on objects
2175+
* nobody registered; the purge rewrote the ledger's `withSampleData` with no
2176+
* row deleted.
2177+
*
2178+
* The answer is the install route's for the same manifest: `422
2179+
* OS_PROTOCOL_INCOMPATIBLE`, the error's own message, and the diagnostic's
2180+
* five fields in `error.details`, from the producer's one shaping helper
2181+
* (`protocolIncompatibleAnswer`). Each door asks this right after it has
2182+
* read the entry, before any side effect.
2183+
*
2184+
* Judged with `checkProtocolCompat` on the entry itself, as the rehydrate
2185+
* judges it, and NOT read from {@link refusedAtRehydrate}: an entry another
2186+
* runtime wrote to a shared ledger after this boot was never rehydrated
2187+
* here, and the doors refuse it all the same. Only a positive
2188+
* incompatibility is refused. An absent or unrecognised range is admitted,
2189+
* with no warning, so a loadable entry is answered exactly as before.
2190+
* DELETE and a compatible re-install do not ask this: they are how an
2191+
* operator gets out of the refused state.
2192+
*/
2193+
private refuseProtocolIncompatibleEntry = (c: any, entry: InstalledEntry): Response | undefined => {
2194+
const compat = checkProtocolCompat(entry.manifest);
2195+
if (compat.status !== 'incompatible') return undefined;
2196+
const refusal = protocolIncompatibleAnswer(new ProtocolIncompatibleError(compat.diagnostic));
2197+
return c.json({
2198+
success: false,
2199+
error: { code: refusal.code, message: refusal.message, details: refusal.details },
2200+
}, refusal.status);
2201+
};
2202+
21432203
/**
21442204
* [#21321] Bind an installed package's executable handlers — its
21452205
* `type: 'script'` action bodies and its body hooks — through

0 commit comments

Comments
 (0)