From 04c8d444e019ca337c5f2d96656f4c33b8ff25f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:57:49 +0000 Subject: [PATCH 1/5] fix(cloud-connection): reseed and purge run the protocol handshake on the ledger entry before any side effect Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU --- .../src/marketplace-install-local-plugin.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 645592322ba..083c38d6e8a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -70,7 +70,10 @@ * cloud round-trips. An entry whose `engines.protocol` excludes this runtime's * major is not loaded: it is reported at `error`, naming the replay command, * and the boot continues (ADR-0087 D1, #21762). The GET listing marks it as - * not loaded for as long as that ledger entry stands (#21822). + * not loaded for as long as that ledger entry stands (#21822). The reseed and + * purge doors run the handshake on the entry themselves and refuse such an + * entry with the install route's `OS_PROTOCOL_INCOMPATIBLE` (422) before any + * side effect; DELETE and a compatible re-install still act on it (#21834). */ import type { Plugin, PluginContext } from '@objectstack/core'; @@ -101,12 +104,14 @@ import { ManifestSchema, manifestIdRefusal } from '@objectstack/spec/kernel'; // [#21762] ADR-0087 D1's protocol handshake, its brand predicate and the ONE // answer both package-install doors give its refusal. Imported from the // producer, never restated: `POST /api/v1/packages` answers through the same -// helper, so the two doors cannot drift apart on the wire. +// helper, so the two doors cannot drift apart on the wire. [#21834] The +// sample-data doors answer a refused ledger entry through it too. import { assertProtocolCompat, checkProtocolCompat, isProtocolIncompatibleError, protocolIncompatibleAnswer, + ProtocolIncompatibleError, type ProtocolIncompatibleDiagnostic, } from '@objectstack/metadata-core'; import { resolveCloudUrl } from './cloud-url.js'; @@ -1937,6 +1942,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * ADR-0123 D2 / D4's answer ({@link noActiveOrganizationRefusal}); the * reseed's other declines (no seed datasets, no data engine or metadata * service, a seed run that threw) stay `400 RESEED_SKIPPED`. + * + * [#21834] An entry whose declared protocol range excludes this runtime is + * refused first, with the install route's `422 OS_PROTOCOL_INCOMPATIBLE` + * ({@link refuseProtocolIncompatibleEntry}): ahead of the side effects, + * because both of those answers are decided after them. */ private handleReseed = async (c: any, ctx: PluginContext): Promise => { const admission = await this.requireInstallCapability(c, ctx, 'Reseeding sample data'); @@ -1955,6 +1965,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!entry) { return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'reseed sample data'); } + // [#21834] ADR-0087 D1: the entry is judged before `applySideEffects` + // loads its translations and merges its seed datasets. + const incompatible = this.refuseProtocolIncompatibleEntry(c, entry); + if (incompatible) return incompatible; const summary = await this.applySideEffects(ctx, entry.manifest, { seedNow: true, c, door: 'reseed' }); // [ADR-0123 D2 / D4] A walled session with no active organization: the @@ -2056,6 +2070,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * `skipped` counts seed records no row in scope carries (already deleted); * `errors` counts records that could not be purged — refused by the engine, * or not identifiable — each reason logged. + * + * [#21834] An entry whose declared protocol range excludes this runtime is + * refused first, with the install route's `422 OS_PROTOCOL_INCOMPATIBLE` + * ({@link refuseProtocolIncompatibleEntry}): no row is deleted and the + * ledger's `withSampleData` / `sampleDataPurged` are not rewritten. */ private handlePurge = async (c: any, ctx: PluginContext): Promise => { const admission = await this.requireInstallCapability(c, ctx, 'Purging sample data'); @@ -2074,6 +2093,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!entry) { return this.unreadableLedgerEntry(c, ctx, manifestId, failure, 'purge sample data'); } + // [#21834] ADR-0087 D1: the entry is judged before any row is matched, + // deleted, or the ledger is written. + const incompatible = this.refuseProtocolIncompatibleEntry(c, entry); + if (incompatible) return incompatible; const datasets = seedDatasetsOf(entry.manifest); @@ -2140,6 +2163,43 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 200); }; + /** + * [#21834] ADR-0087 D1's handshake on the two sample-data doors: the answer + * reseed and purge give a ledger entry whose declared protocol range + * excludes this runtime, or `undefined` for an entry they may act on. + * + * The rehydrate does not load such an entry: nothing is registered, synced, + * bound or seeded for it. The two doors used to act on it anyway. The reseed + * loaded its translations into the i18n service and merged its seed datasets + * into the shared `seed-datasets` list before its seed run failed on objects + * nobody registered; the purge rewrote the ledger's `withSampleData` with no + * row deleted. + * + * The answer is the install route's for the same manifest: `422 + * OS_PROTOCOL_INCOMPATIBLE`, the error's own message, and the diagnostic's + * five fields in `error.details`, from the producer's one shaping helper + * (`protocolIncompatibleAnswer`). Each door asks this right after it has + * read the entry, before any side effect. + * + * Judged with `checkProtocolCompat` on the entry itself, as the rehydrate + * judges it, and NOT read from {@link refusedAtRehydrate}: an entry another + * runtime wrote to a shared ledger after this boot was never rehydrated + * here, and the doors refuse it all the same. Only a positive + * incompatibility is refused. An absent or unrecognised range is admitted, + * with no warning, so a loadable entry is answered exactly as before. + * DELETE and a compatible re-install do not ask this: they are how an + * operator gets out of the refused state. + */ + private refuseProtocolIncompatibleEntry = (c: any, entry: InstalledEntry): Response | undefined => { + const compat = checkProtocolCompat(entry.manifest); + if (compat.status !== 'incompatible') return undefined; + const refusal = protocolIncompatibleAnswer(new ProtocolIncompatibleError(compat.diagnostic)); + return c.json({ + success: false, + error: { code: refusal.code, message: refusal.message, details: refusal.details }, + }, refusal.status); + }; + /** * [#21321] Bind an installed package's executable handlers — its * `type: 'script'` action bodies and its body hooks — through From 948c8fb0cee1bae4e55920f2c60b1fc3fb747289 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:01:41 +0000 Subject: [PATCH 2/5] test(cloud-connection): pin reseed and purge refusing a protocol-incompatible ledger entry before any side effect Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU --- ...stall-local-sample-data-not-loaded.test.ts | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts new file mode 100644 index 00000000000..d6fa378d17f --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -0,0 +1,397 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21834] The two sample-data doors run ADR-0087 D1's handshake on the ledger + * entry they are asked to act on, and refuse an entry whose declared protocol + * range excludes this runtime before any side effect. + * + * ## The defect this file pins shut + * + * The `kernel:ready` rehydrate does not load such an entry (#21762): nothing is + * registered, synced, bound or seeded for it. `reseed-sample-data` and + * `purge-sample-data` acted on it anyway. The reseed loaded the package's + * translations into the i18n service and merged its seed datasets into the + * shared `seed-datasets` list, and only then failed (`400 RESEED_SKIPPED`) on + * objects nobody registered. The purge rewrote the ledger's `withSampleData` + * with no row deleted. + * + * ## What these cases pin (triage ruling `5990436064`) + * + * - on a refused entry, reseed and purge answer the install route's + * `422 OS_PROTOCOL_INCOMPATIBLE` for the same manifest, and leave the i18n + * service, the seed-dataset list, the engine and the ledger unchanged; + * - that holds ahead of the organization wall's refusal too, which the + * reseed used to decide after its side effects; + * - the doors judge the entry themselves: an entry another runtime wrote to + * the ledger after this boot's rehydrate is refused as well; + * - on a loadable entry both doors answer as before, and the probes above see + * their side effects (the control for every "unchanged" below); + * - DELETE and a compatible re-install still act on the refused entry. + * + * A "restart" here is a fresh plugin mounted over a ledger that already holds + * the entries, through its real `start()` + `kernel:ready`, with the REAL + * `SeedLoaderService` over an in-memory engine that answers only for objects a + * package REGISTERED. The real-boot pin across a restart is + * `packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts`. + * + * Ranges derive from the running protocol, never literals. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +// The doors seed and purge through `@objectstack/runtime` (lazy `import()`s +// inside the plugin). Its first load is paid here, at module top, never inside +// a clocked `it`. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; +import { installerGrantRows, INSTALLER_USER_ID } from './install-local-principal.fixtures.js'; + +type Handler = (c: any) => Promise; +type Row = Record & { id: string }; + +const ROUTE = '/api/v1/marketplace/install-local'; +const OLD_RANGE = `^${PROTOCOL_MAJOR - 1}`; +const CURRENT_RANGE = `^${PROTOCOL_MAJOR}`; + +/** Two locales: each application of a package's side effects loads two bundles. */ +const translationsFor = (object: string) => [{ + en: { objects: { [object]: { label: 'Account' } } }, + 'zh-CN': { objects: { [object]: { label: '客户' } } }, +}]; + +/** Installed for the previous protocol major: the rehydrate refuses it. */ +const REFUSED = { + id: 'com.example.qaold21834', name: 'Old', version: '1.0.0', type: 'app', scope: 'project', + engines: { protocol: OLD_RANGE }, + objects: [{ name: 'qa_old_account', fields: { name: { type: 'text' } } }], + translations: translationsFor('qa_old_account'), + data: [{ object: 'qa_old_account', externalId: 'name', records: [{ name: 'Acme' }] }], +}; +/** Built for this protocol: the rehydrate loads it. */ +const LOADED = { + id: 'com.example.qacurrent21834', name: 'Current', version: '1.0.0', type: 'app', scope: 'project', + engines: { protocol: CURRENT_RANGE }, + objects: [{ name: 'qa_cur_account', fields: { name: { type: 'text' } } }], + translations: translationsFor('qa_cur_account'), + data: [{ object: 'qa_cur_account', externalId: 'name', records: [{ name: 'Beta' }] }], +}; +/** Declares no range at all: grandfathered, loaded like before the handshake existed. */ +const NO_RANGE = { + id: 'com.example.qanorange21834', name: 'NoRange', version: '1.0.0', type: 'app', scope: 'project', + objects: [{ name: 'qa_nr_account', fields: { name: { type: 'text' } } }], + data: [{ object: 'qa_nr_account', externalId: 'name', records: [{ name: 'Gamma' }] }], +}; + +function ledgerEntry(manifest: { id: string; version: string }): InstalledManifestEntry { + return { + packageId: manifest.id, + versionId: manifest.version, + manifestId: manifest.id, + version: manifest.version, + manifest, + installedAt: '2026-01-01T00:00:00.000Z', + installedBy: INSTALLER_USER_ID, + withSampleData: true, + }; +} + +/** Every file in the ledger directory and its bytes: "the ledger is unchanged" is this, equal. */ +function ledgerBytes(dir: string): Record { + const out: Record = {}; + for (const f of readdirSync(dir).sort()) out[f] = readFileSync(join(dir, f), 'utf8'); + return out; +} + +/** + * Mount the plugin over a ledger that already holds `manifests`, the way a + * restarted runtime meets it. Each package's seed row is already in the + * database: the refused package's was written while an earlier runtime still + * loaded it. + */ +async function restartWith( + manifests: Array<{ id: string; version: string }>, + dir: string, + opts: { posture?: 'single' | 'isolated' } = {}, +) { + for (const m of manifests) new LocalManifestSource(dir).write(ledgerEntry(m)); + const tables: Record = { + qa_old_account: [{ id: 'o1', name: 'Acme' }], + qa_cur_account: [{ id: 'c1', name: 'Beta' }], + qa_nr_account: [{ id: 'n1', name: 'Gamma' }], + }; + const objects = new Map(); + const writes: string[] = []; + let nextId = 0; + const known = (object: string) => { + if (!objects.has(object)) throw new Error(`Object '${object}' not found`); + return (tables[object] ??= []); + }; + const engine = { + syncSchemas: vi.fn(async () => undefined), + registry: { getAllPackages: () => [] }, + async find(object: string, query?: any): Promise { + const granted = installerGrantRows(); + if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!; + if (object === 'sys_organization') return []; + const where: Record = query?.where ?? {}; + const rows = known(object).filter((row) => Object.entries(where).every(([k, v]) => { + // Scalar equality only; anything else is refused, never guessed. + if (k.startsWith('$') || (v !== null && typeof v === 'object')) { + throw new Error(`only scalar equality is implemented here (got '${k}')`); + } + return row[k] === v; + })); + return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row })); + }, + async insert(object: string, record: Record): Promise { + const row = { ...record, id: `new${++nextId}` } as Row; + known(object).push(row); + writes.push(`insert ${object}#${row.id}`); + return { ...row }; + }, + async delete(object: string, options?: any): Promise { + const dispatch = assertEngineDeleteDispatch(options); + if (dispatch.kind !== 'by-id') throw new Error('fake engine: the purge deletes by primary key only'); + const table = known(object); + const at = table.findIndex((r) => r.id === String(dispatch.id)); + if (at < 0) return false; + table.splice(at, 1); + writes.push(`delete ${object}#${String(dispatch.id)}`); + return true; + }, + }; + const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); + const i18n = { loadTranslations: vi.fn() }; + const services: Record = { + manifest: { register }, + auth: { api: { getSession: async () => ({ user: { id: INSTALLER_USER_ID }, session: {} }) } }, + objectql: engine, + metadata: { getObject: async (name: string) => objects.get(name) }, + i18n, + ...(opts.posture ? { tenancy: { posture: opts.posture } } : {}), + }; + const routes = new Map(); + const rawApp = { + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; + const hooks = new Map(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + const door = (verb: 'reseed-sample-data' | 'purge-sample-data') => (manifestId: string) => + routes.get(`POST ${ROUTE}/:manifestId/${verb}`)!(makeC({}, { manifestId })); + return { + tables, + writes, + logger, + registered: () => register.mock.calls.map(([m]) => m?.id), + /** What the ruling says the doors must leave unchanged, read in one go. */ + state: () => ({ + translationsLoaded: i18n.loadTranslations.mock.calls.length, + seedDatasets: (services['seed-datasets'] as unknown[] | undefined)?.length ?? 0, + engineWrites: writes.length, + ledger: ledgerBytes(dir), + }), + reseed: door('reseed-sample-data'), + purge: door('purge-sample-data'), + uninstall: (manifestId: string) => routes.get(`DELETE ${ROUTE}/:manifestId`)!(makeC(undefined, { manifestId })), + install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)), + }; +} + +function makeC(body?: unknown, params: Record = {}) { + return { + req: { + url: `http://localhost:3000${ROUTE}`, + raw: new Request(`http://localhost:3000${ROUTE}`), + json: async () => body, + param: (k: string) => params[k], + header: () => undefined, + }, + json: vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })), + }; +} + +/** The ledger's record for one entry, read from the ledger itself. */ +const recorded = (dir: string, manifestId: string) => { + const e = new LocalManifestSource(dir).read(manifestId).entry; + return e ? { withSampleData: e.withSampleData, sampleDataPurged: e.sampleDataPurged } : undefined; +}; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-sample-not-loaded-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +describe('reseed and purge on an entry the rehydrate refused to load', () => { + it('PRECONDITION: the rehydrate refused the old entry, loaded the current one, and the probes see the loaded one\'s side effects', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + expect(h.registered()).toContain(LOADED.id); + expect(h.registered()).not.toContain(REFUSED.id); + const said = h.logger.error.mock.calls.map(([m]) => String(m)); + expect(said).toHaveLength(1); + expect(said[0]).toContain(`OS_PROTOCOL_INCOMPATIBLE: ${REFUSED.id}@1.0.0 is NOT loaded`); + // The loaded package's two bundles and its one dataset; none of the refused one's. + expect(h.state()).toMatchObject({ translationsLoaded: 2, seedDatasets: 1, engineWrites: 0 }); + }); + + it('reseed answers the install route\'s 422 OS_PROTOCOL_INCOMPATIBLE, and leaves i18n, seed-datasets, the engine and the ledger unchanged', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const before = h.state(); + const res = await h.reseed(REFUSED.id); + const after = h.state(); + + expect(res.status, JSON.stringify(res.payload)).toBe(422); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(res.payload.error.details).toMatchObject({ requiredRange: OLD_RANGE, rangeSource: 'engines.protocol' }); + expect(after).toEqual(before); + + // The install route's answer for the same manifest, byte for byte. + const install = await h.install({ manifest: REFUSED }); + expect(install.status).toBe(422); + expect(JSON.stringify(res.payload)).toBe(JSON.stringify(install.payload)); + }); + + it('purge answers the same 422, deletes nothing, and leaves withSampleData and the rest of the ledger unchanged', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const before = h.state(); + const res = await h.purge(REFUSED.id); + + expect(res.status, JSON.stringify(res.payload)).toBe(422); + expect(res.payload.success).toBe(false); + expect(res.payload.error.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(res.payload.error.details).toMatchObject({ requiredRange: OLD_RANGE }); + expect(h.state()).toEqual(before); + expect(recorded(dir, REFUSED.id)).toEqual({ withSampleData: true, sampleDataPurged: undefined }); + expect(h.tables.qa_old_account).toEqual([{ id: 'o1', name: 'Acme' }]); + + const install = await h.install({ manifest: REFUSED }); + expect(JSON.stringify(res.payload)).toBe(JSON.stringify(install.payload)); + }); + + it('walled, with no active organization: the refused entry is answered 422 ahead of the wall\'s 403, and nothing moves', async () => { + const h = await restartWith([REFUSED, LOADED], dir, { posture: 'isolated' }); + const before = h.state(); + + const reseed = await h.reseed(REFUSED.id); + const purge = await h.purge(REFUSED.id); + + expect([reseed.status, reseed.payload.error.code]).toEqual([422, 'OS_PROTOCOL_INCOMPATIBLE']); + expect([purge.status, purge.payload.error.code]).toEqual([422, 'OS_PROTOCOL_INCOMPATIBLE']); + expect(h.state()).toEqual(before); + + // Control: the same session on the loadable entry meets the wall, so + // the wall is in force in this boot. + const walled = await h.purge(LOADED.id); + expect([walled.status, walled.payload.error.code]).toEqual([403, 'PERMISSION_DENIED']); + }); + + it('an entry written to the ledger after this boot\'s rehydrate is judged by the door itself', async () => { + const h = await restartWith([LOADED], dir); + // Another runtime, of the previous protocol, shares this ledger. + new LocalManifestSource(dir).write(ledgerEntry(REFUSED)); + const before = h.state(); + + const reseed = await h.reseed(REFUSED.id); + const purge = await h.purge(REFUSED.id); + + expect([reseed.status, reseed.payload.error.code]).toEqual([422, 'OS_PROTOCOL_INCOMPATIBLE']); + expect([purge.status, purge.payload.error.code]).toEqual([422, 'OS_PROTOCOL_INCOMPATIBLE']); + expect(h.state()).toEqual(before); + }); + + it('DELETE on the refused entry still works after both doors refused it', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + expect((await h.reseed(REFUSED.id)).status).toBe(422); + expect((await h.purge(REFUSED.id)).status).toBe(422); + + const res = await h.uninstall(REFUSED.id); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload.data.manifestId).toBe(REFUSED.id); + expect(new LocalManifestSource(dir).has(REFUSED.id)).toBe(false); + }); + + it('a compatible version installed over the refused entry: reseed and purge then act on it', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + expect((await h.reseed(REFUSED.id)).status).toBe(422); + + const installed = await h.install({ manifest: { ...REFUSED, version: '2.0.0', engines: { protocol: CURRENT_RANGE } } }); + expect(installed.status, JSON.stringify(installed.payload)).toBe(200); + expect(h.registered()).toContain(REFUSED.id); + + const reseed = await h.reseed(REFUSED.id); + expect(reseed.status, JSON.stringify(reseed.payload)).toBe(200); + expect(reseed.payload.data).toEqual({ manifestId: REFUSED.id, inserted: 0, updated: 0, skipped: 1, errors: 0, withSampleData: true }); + + const purge = await h.purge(REFUSED.id); + expect(purge.status, JSON.stringify(purge.payload)).toBe(200); + expect(purge.payload.data).toEqual({ manifestId: REFUSED.id, deleted: 1, skipped: 0, errors: 0, withSampleData: false }); + expect(h.tables.qa_old_account).toEqual([]); + }); +}); + +describe('reseed and purge on a loadable entry answer as before', () => { + it('reseed: 200 with the loader\'s counts, and its side effects land (the control for every "unchanged" above)', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const before = h.state(); + const res = await h.reseed(LOADED.id); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload).toEqual({ + success: true, + data: { manifestId: LOADED.id, inserted: 0, updated: 0, skipped: 1, errors: 0, withSampleData: true }, + }); + const after = h.state(); + expect(after.translationsLoaded - before.translationsLoaded).toBe(2); + expect(after.seedDatasets - before.seedDatasets).toBe(1); + expect(recorded(dir, LOADED.id)).toEqual({ withSampleData: true, sampleDataPurged: false }); + }); + + it('purge: 200 with its counts, the seed row deleted, and the ledger rewritten (the control for the ledger probe)', async () => { + const h = await restartWith([REFUSED, LOADED], dir); + const before = h.state(); + const res = await h.purge(LOADED.id); + + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(res.payload).toEqual({ + success: true, + data: { manifestId: LOADED.id, deleted: 1, skipped: 0, errors: 0, withSampleData: false }, + }); + expect(h.writes).toEqual(['delete qa_cur_account#c1']); + expect(h.state().ledger).not.toEqual(before.ledger); + expect(recorded(dir, LOADED.id)).toEqual({ withSampleData: false, sampleDataPurged: true }); + }); + + it('an entry declaring no range is admitted, with no protocol warning from either door', async () => { + const h = await restartWith([NO_RANGE], dir); + h.logger.warn.mockClear(); + + const reseed = await h.reseed(NO_RANGE.id); + const purge = await h.purge(NO_RANGE.id); + + expect(reseed.status, JSON.stringify(reseed.payload)).toBe(200); + expect(purge.status, JSON.stringify(purge.payload)).toBe(200); + expect(purge.payload.data).toMatchObject({ deleted: 1 }); + const protocolWarnings = h.logger.warn.mock.calls.map(([m]) => String(m)).filter((m) => m.includes('[protocol]')); + expect(protocolWarnings).toEqual([]); + }); +}); From 2585beaab88c8d90c98aa963319bbcda53a8d490 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:04:18 +0000 Subject: [PATCH 3/5] test(dogfood): pin reseed and purge refusing a package the restart refused to load, on two real boots Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU --- ...cal-sample-data-not-loaded.dogfood.test.ts | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts diff --git a/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts b/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts new file mode 100644 index 00000000000..5bb21b2e74c --- /dev/null +++ b/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — after a restart whose `kernel:ready` rehydrate refused a +// protocol-incompatible package, `reseed-sample-data` and `purge-sample-data` +// refuse it with the install route's `422 OS_PROTOCOL_INCOMPATIBLE` before any +// side effect, and a compatible re-install gets the operator out (#21834). +// +// ## What was measured before the fix +// +// On a real boot whose install-local ledger held `com.example.crm` declaring the +// previous protocol major, the rehydrate refused it (0 of its 5 objects +// registered). Then, as the admin: +// +// POST …/com.example.crm/reseed-sample-data +// -> 400 RESEED_SKIPPED "Reseed did not run: seed-error: Object 'crm_account' not found", +// AFTER loading the package's translations into the i18n service and +// merging its 5 seed datasets into the kernel's shared `seed-datasets` list +// POST …/com.example.crm/purge-sample-data +// -> 200 { deleted: 0, skipped: 0, errors: 28, withSampleData: false }, +// rewriting the ledger's `withSampleData` from true to false +// +// ## What this file pins, on two real boots over one database file and one ledger +// +// 1. PRECONDITIONS: boot 1 installed the CRM package with its 28 seed rows, +// and a reseed there (the package loadable) loaded its translations, so the +// i18n probe is shown to see the door's loads; boot 2's rehydrate refused +// the package once its ledger entry declared the previous major. +// 2. On boot 2 both doors answer 422 `OS_PROTOCOL_INCOMPATIBLE`, byte-identical +// to the install route's answer for the same manifest, and leave the i18n +// service, the `seed-datasets` list and the ledger exactly as they were. +// 3. A compatible version installed over the refused entry answers 200; on it +// the reseed answers 200 (the 28 rows intact) and loads translations +// through the same probe, and the purge answers 200 deleting the 28 rows. +// DELETE then removes it. (DELETE on a still-refused entry at real boot is +// `install-local-listing-not-loaded.dogfood.test.ts`.) +// +// Boots its own showcase stack, twice, so it stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { PROTOCOL_MAJOR, type ExecutionContext } from '@objectstack/spec/kernel'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const SYS = { isSystem: true } as ExecutionContext; +const CRM = 'com.example.crm'; +const BASE = '/marketplace/install-local'; +const OLD_RANGE = `^${PROTOCOL_MAJOR - 1}`; +/** The CRM example's seeded objects and their seed-row counts (3 + 3 + 12 + 5 + 5). */ +const SEEDED: Record = { + crm_account: 3, crm_contact: 3, crm_opportunity: 12, crm_lead: 5, crm_activity: 5, +}; +const SEED_TOTAL = Object.values(SEEDED).reduce((a, b) => a + b, 0); +/** One seed dataset per seeded object. */ +const CRM_DATASETS = Object.keys(SEEDED).length; + +type Answer = { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +/** The install body `os package install .json` sends. */ +function crmInstallBody(): { manifest: Record } { + const { artifact } = buildShapedArtifact(crmStack as unknown as Record); + const manifest = artifact.manifest as { id?: string; version?: string }; + return { manifest: { ...artifact, id: manifest.id, version: manifest.version } }; +} + +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +async function countSeeded(ql: IObjectQLEngine): Promise { + let n = 0; + for (const object of Object.keys(SEEDED)) n += rowsOf(await ql.find(object, { context: SYS })).length; + return n; +} + +/** Every line the process writes while `run` is in flight — the kernel logger writes to the streams. */ +async function captureOutput(run: () => Promise): Promise<{ value: T; lines: string[] }> { + const lines: string[] = []; + const stdout = process.stdout.write.bind(process.stdout); + const stderr = process.stderr.write.bind(process.stderr); + (process.stdout as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stdout(chunk as any, ...rest); }; // eslint-disable-line @typescript-eslint/no-explicit-any + (process.stderr as any).write = (chunk: unknown, ...rest: any[]) => { lines.push(String(chunk)); return stderr(chunk as any, ...rest); }; // eslint-disable-line @typescript-eslint/no-explicit-any + try { + return { value: await run(), lines }; + } finally { + (process.stdout as any).write = stdout; // eslint-disable-line @typescript-eslint/no-explicit-any + (process.stderr as any).write = stderr; // eslint-disable-line @typescript-eslint/no-explicit-any + } +} + +/** + * What the ruling says the doors leave unchanged, read off the booted kernel: + * the `i18n` service (which object it is, and every `loadTranslations` call it + * receives from here on), the shared `seed-datasets` list, and the ledger's + * bytes on disk. + */ +function probeKernel(stack: VerifyStack, storageDir: string) { + const service = (name: string): unknown => { + try { return stack.kernel.getService(name); } catch { return undefined; } + }; + const i18n = service('i18n') as { loadTranslations?: (...args: unknown[]) => unknown } | undefined; + let loads = 0; + if (i18n && typeof i18n.loadTranslations === 'function') { + const original = i18n.loadTranslations; + i18n.loadTranslations = (...args: unknown[]) => { loads++; return original.apply(i18n, args); }; + } + return { + state: () => { + const datasets = service('seed-datasets'); + const ledger: Record = {}; + for (const f of readdirSync(storageDir).sort()) ledger[f] = readFileSync(join(storageDir, f), 'utf8'); + return { + i18nService: service('i18n') === i18n ? 'same' : 'replaced', + translationLoads: loads, + seedDatasets: Array.isArray(datasets) ? datasets.length : 'absent', + ledger, + }; + }, + }; +} + +describe('dogfood: reseed and purge refuse a package the restart refused to load, before any side effect (#21834)', () => { + let stack: VerifyStack | undefined; + let storageDir: string; + let dbDir: string; + let crmInstall: Answer; + let boot1Reseed: Answer; + let boot1Loads: number; + let crmVersion: string; + let bootLines: string[]; + let before: ReturnType['state']>; + let reseedRefused: Answer; + let purgeRefused: Answer; + let after: ReturnType['state']>; + let recordedAfterRefusals: unknown; + let installRefused: Answer; + let reinstall: Answer; + let rowsAfterReinstall: number; + let beforeReseed: ReturnType['state']>; + let reseedLoaded: Answer; + let afterReseed: ReturnType['state']>; + let purgeLoaded: Answer; + let rowsAfterPurge: number; + let recordedAfterPurge: unknown; + let uninstall: Answer; + + const boot = (databaseFile: string) => bootStack(showcaseStack, { + databaseFile, + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-sample-not-loaded-ledger-')); + dbDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-sample-not-loaded-db-')); + const databaseFile = join(dbDir, 'verify.db'); + + // ── boot 1: install the CRM package; a reseed while it is loadable ───── + stack = await boot(databaseFile); + let token = await stack.signIn(); + crmInstall = await json(await stack.apiAs(token, 'POST', BASE, crmInstallBody())); + const boot1 = probeKernel(stack, storageDir); + boot1Reseed = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); + boot1Loads = boot1.state().translationLoads; + await stack.stop(); + stack = undefined; + + // ── between the boots: the CRM install now declares the previous major ─ + const ledger = new LocalManifestSource(storageDir); + const crm = ledger.read(CRM).entry!; + crmVersion = crm.version; + ledger.write({ ...crm, manifest: { ...crm.manifest, engines: { ...(crm.manifest?.engines ?? {}), protocol: OLD_RANGE } } }); + + // ── boot 2: the same database file and ledger ────────────────────────── + const booted = await captureOutput(() => boot(databaseFile)); + stack = booted.value; + bootLines = booted.lines; + token = await stack.signIn(); + const probe = probeKernel(stack, storageDir); + + before = probe.state(); + reseedRefused = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); + purgeRefused = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); + after = probe.state(); + recordedAfterRefusals = new LocalManifestSource(storageDir).read(CRM).entry?.withSampleData; + // The install route's answer for the very manifest the ledger holds. + installRefused = await json(await stack.apiAs(token, 'POST', BASE, { manifest: new LocalManifestSource(storageDir).read(CRM).entry!.manifest })); + + // ── the way out: a compatible version installed over the refused entry ─ + reinstall = await json(await stack.apiAs(token, 'POST', BASE, crmInstallBody())); + const ql = stack.kernel.getService('objectql'); + rowsAfterReinstall = await countSeeded(ql); + beforeReseed = probe.state(); + reseedLoaded = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/reseed-sample-data`, {})); + afterReseed = probe.state(); + purgeLoaded = await json(await stack.apiAs(token, 'POST', `${BASE}/${CRM}/purge-sample-data`, {})); + rowsAfterPurge = await countSeeded(ql); + recordedAfterPurge = new LocalManifestSource(storageDir).read(CRM).entry?.withSampleData; + uninstall = await json(await stack.apiAs(token, 'DELETE', `${BASE}/${CRM}`)); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + if (dbDir) rmSync(dbDir, { recursive: true, force: true }); + }); + + it('PRECONDITION: boot 1 installed the CRM package with its seed rows, and its reseed there loaded translations through the probe', () => { + expect(crmInstall.status, JSON.stringify(crmInstall.body)).toBe(200); + expect(crmInstall.body?.data?.seeded).toMatchObject({ mode: 'inline', inserted: SEED_TOTAL }); + expect(boot1Reseed.status, JSON.stringify(boot1Reseed.body)).toBe(200); + expect(boot1Reseed.body?.data).toMatchObject({ inserted: 0, skipped: SEED_TOTAL, errors: 0 }); + expect(boot1Loads).toBeGreaterThan(0); + }); + + it('PRECONDITION: boot 2\'s rehydrate refused the CRM package', () => { + const refused = bootLines.filter((l) => l.includes(`OS_PROTOCOL_INCOMPATIBLE: ${CRM}@${crmVersion} is NOT loaded`)); + expect(refused, 'the rehydrate\'s refusal line').toHaveLength(1); + }); + + it('reseed answers 422 OS_PROTOCOL_INCOMPATIBLE, the install route\'s answer for the same manifest', () => { + expect(reseedRefused.status, JSON.stringify(reseedRefused.body)).toBe(422); + expect(reseedRefused.body?.success).toBe(false); + expect(reseedRefused.body?.error?.code).toBe('OS_PROTOCOL_INCOMPATIBLE'); + expect(reseedRefused.body?.error?.details).toMatchObject({ requiredRange: OLD_RANGE, rangeSource: 'engines.protocol' }); + expect(installRefused.status).toBe(422); + expect(JSON.stringify(reseedRefused.body)).toBe(JSON.stringify(installRefused.body)); + }); + + it('purge answers the same 422', () => { + expect(purgeRefused.status, JSON.stringify(purgeRefused.body)).toBe(422); + expect(JSON.stringify(purgeRefused.body)).toBe(JSON.stringify(installRefused.body)); + }); + + it('the i18n service, the seed-datasets list and the ledger are exactly as they were before both doors', () => { + expect(after).toEqual(before); + expect(after.i18nService).toBe('same'); + expect(after.translationLoads).toBe(0); + expect(recordedAfterRefusals).toBe(true); + }); + + it('a compatible version installed over the refused entry: reseed and purge act on it, and the probes see the reseed\'s side effects', () => { + expect(reinstall.status, JSON.stringify(reinstall.body)).toBe(200); + expect(rowsAfterReinstall).toBe(SEED_TOTAL); + expect(reseedLoaded.status, JSON.stringify(reseedLoaded.body)).toBe(200); + expect(reseedLoaded.body?.data).toMatchObject({ inserted: 0, skipped: SEED_TOTAL, errors: 0, withSampleData: true }); + // Control for the "unchanged" above: the same probes, on the same boot, move. + expect(afterReseed.translationLoads - beforeReseed.translationLoads).toBe(boot1Loads); + expect([typeof beforeReseed.seedDatasets, typeof afterReseed.seedDatasets]).toEqual(['number', 'number']); + expect((afterReseed.seedDatasets as number) - (beforeReseed.seedDatasets as number)).toBe(CRM_DATASETS); + expect(purgeLoaded.status, JSON.stringify(purgeLoaded.body)).toBe(200); + expect(purgeLoaded.body?.data).toMatchObject({ deleted: SEED_TOTAL, skipped: 0, errors: 0, withSampleData: false }); + expect(rowsAfterPurge).toBe(0); + expect(recordedAfterPurge).toBe(false); + }); + + it('DELETE then removes it', () => { + expect(uninstall.status, JSON.stringify(uninstall.body)).toBe(200); + expect(uninstall.body?.data?.manifestId).toBe(CRM); + expect(new LocalManifestSource(storageDir).has(CRM)).toBe(false); + }); +}); From 9f60b0454b48184de71faeaf79485ee76fd79561 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:04:38 +0000 Subject: [PATCH 4/5] chore(changeset): cloud-connection patch for the sample-data doors' protocol handshake Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU --- .changeset/21834-reseed-purge-protocol-handshake.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/21834-reseed-purge-protocol-handshake.md diff --git a/.changeset/21834-reseed-purge-protocol-handshake.md b/.changeset/21834-reseed-purge-protocol-handshake.md new file mode 100644 index 00000000000..f49990697af --- /dev/null +++ b/.changeset/21834-reseed-purge-protocol-handshake.md @@ -0,0 +1,11 @@ +--- +"@objectstack/cloud-connection": patch +--- + +`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. + +Clause-②: no + +- **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. +- **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. +- **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. From 92261ad58600ee75e5ef14da0b8d478f6c8838c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:20:55 +0000 Subject: [PATCH 5/5] chore(gates): record the new suite's pinned engine double in the retained ledger Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 19c69f678a8..fc53feba06b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1,6 +1,11 @@ { "$comment": "GENERATED — the RETAINED ledger of check-engine-double-contract.mjs (#9680). Regenerate with `node scripts/check-engine-double-contract.mjs --write`; never hand-edit. Each row is one (file, verb) whose engine double routes through the producer's dispatch predicate. This is the OPPOSITE polarity to engine-double-contract.baseline.json: that ledger records DEBT and may only shrink, this one records COVERAGE and may only grow. A row that disappears is a pinned double that left the population — the blind spot #9680 measured, where deleting a double's delete() member took 319 pinned to 318 with the gate green. Read a removal in this file's diff as a coverage loss and check it was intended.", "entries": [ + { + "file": "packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/core/src/security/resolve-user-grants-cache.test.ts", "verb": "delete",