From 552ce62efd065647706e1eacd92b0cd9acd98e40 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 00:59:50 +0000 Subject: [PATCH] feat(platform-objects,service-storage,cli): register the sys_migration ledger as platform infrastructure (#4243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment-level data-migration flag ledger (sys_migration, #3617) was registered by service-storage as its first consumer. The ledger now gates storage-independent behaviour too (os migrate value-shapes #4235, creation attestation #4215), so the registration moves to PlatformObjectsPlugin — present on every supported assembly path — and the fresh-datastore attestation moves with it. buildDataMigrationPlugins() no longer boots storage for every gated migration: PlatformObjectsPlugin always, settings + storage only for the file migration ({ storage: true }). Verified end-to-end: a fresh `os serve` boot carries the ledger and both attestation rows; `os migrate value-shapes` now boots without file-storage and still finds (and attests) the ledger. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UDj5U2VTHJE5eSyiDuqfUS --- .../sys-migration-ledger-platform-infra.md | 39 +++++ .../commands/migrate/files-to-references.ts | 2 +- packages/cli/src/commands/serve.ts | 13 +- .../cli/src/utils/data-migration-plugins.ts | 60 ++++---- packages/platform-objects/src/plugin.test.ts | 145 ++++++++++++++++++ packages/platform-objects/src/plugin.ts | 101 ++++++++++-- .../src/system/sys-migration.object.ts | 10 +- .../src/storage-service-plugin.test.ts | 82 +--------- .../src/storage-service-plugin.ts | 48 ++---- 9 files changed, 330 insertions(+), 170 deletions(-) create mode 100644 .changeset/sys-migration-ledger-platform-infra.md create mode 100644 packages/platform-objects/src/plugin.test.ts diff --git a/.changeset/sys-migration-ledger-platform-infra.md b/.changeset/sys-migration-ledger-platform-infra.md new file mode 100644 index 0000000000..0a84e8cc86 --- /dev/null +++ b/.changeset/sys-migration-ledger-platform-infra.md @@ -0,0 +1,39 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/service-storage": patch +"@objectstack/cli": patch +--- + +feat(platform-objects,service-storage,cli): `sys_migration` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the storage service (#4243) + +The deployment-level data-migration flag ledger (`sys_migration`, #3617) was +registered by `@objectstack/service-storage` as its first consumer. That was +deliberate while the file migration was the only consumer, but the ledger now +gates storage-independent behaviour too — `os migrate value-shapes` (#4235) +and the fresh-datastore attestation (#4215) — and a non-file migration had to +boot the whole storage plugin just so the kernel carried the table. Any kernel +assembled without storage silently had no ledger at all, which read exactly +like "migration not run" (both answer false) while actually meaning "ledger +not installed". + +The registration now lives in `PlatformObjectsPlugin` +(`@objectstack/platform-objects/plugin`) — the plugin `os serve` already +auto-injects into every served kernel — so the ledger exists with the +platform, independent of which optional services are composed. The +fresh-datastore attestation (#3438, ADR-0104) moves with it: it is ledger +bookkeeping, and its old home justified itself as "the service that registers +`sys_migration`". Definition ownership is unchanged (`sys_migration` stays in +`@objectstack/platform-objects` and in `PLATFORM_OBJECTS_BY_PACKAGE`); the +flag helpers and readers are untouched. + +Consequences: + +- `@objectstack/service-storage` no longer contributes `sys_migration` to the + manifest and no longer performs the fresh-datastore attestation. An embedder + composing `StorageServicePlugin` on a hand-built kernel that relied on it + for the ledger must compose `PlatformObjectsPlugin` (the plugin every + supported assembly path already includes). +- The CLI's `buildDataMigrationPlugins()` no longer boots storage for every + gated migration — it registers `PlatformObjectsPlugin` always, and settings + + storage only for `os migrate files-to-references` (`{ storage: true }`), + the one migration that actually reconciles against the storage adapter. diff --git a/packages/cli/src/commands/migrate/files-to-references.ts b/packages/cli/src/commands/migrate/files-to-references.ts index 0a712f412b..a48cfa58b9 100644 --- a/packages/cli/src/commands/migrate/files-to-references.ts +++ b/packages/cli/src/commands/migrate/files-to-references.ts @@ -162,7 +162,7 @@ export default class MigrateFilesToReferences extends Command { try { stack = await bootSchemaStack({ databaseUrl: flags['database-url'], - extraPlugins: await buildDataMigrationPlugins(), + extraPlugins: await buildDataMigrationPlugins({ storage: true }), }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d1e8713635..337da3f518 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1490,12 +1490,15 @@ export default class Serve extends Command { } } - // 5c. Auto-register PlatformObjectsPlugin so platform-default + // 5c. Auto-register PlatformObjectsPlugin. It carries platform + // infrastructure every served kernel needs: the `sys_migration` + // data-migration flag ledger + fresh-datastore attestation (#4243 — + // without it the engine's deployment gates read "no ledger" and fall + // back to the lax legacy posture), and the platform-default // translation bundles (Setup App + metadata-type configuration - // forms shipped by @objectstack/platform-objects) are contributed - // into the kernel's i18n service. Without this, Setup nav labels - // and metadata-admin form labels fall back to English literals - // even when Accept-Language requests another locale. + // forms). Without the latter, Setup nav labels and metadata-admin + // form labels fall back to English literals even when + // Accept-Language requests another locale. const hasPlatformObjectsPlugin = plugins.some( (p: any) => p?.name === 'com.objectstack.platform-objects' || p?.constructor?.name === 'PlatformObjectsPlugin' diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index d7ca4fbb74..8e5019b322 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -3,38 +3,42 @@ import { resolveStorageCapabilityArg } from '../commands/serve.js'; /** - * The service plugins a gated data migration boots with, so the kernel carries - * `sys_migration` (the deployment-level flag ledger) — and, for the file - * migration, `sys_file` plus the deployment's REAL storage adapter. + * The plugins a gated data migration boots with. * - * Settings first: the storage plugin re-resolves its adapter from persisted - * settings when a settings service is present, which is how an S3-configured - * deployment's backfill uploads land in S3 rather than on this machine. - * Storage config goes through the SAME resolver `os serve` uses - * (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly where - * the server would. It did not, and that mattered more here than anywhere: the - * file migration reconciles what records claim against what storage actually - * holds, so a root that disagrees with the server's reconciles against the - * wrong tree. + * Every gated migration needs the `sys_migration` flag ledger (#3617), and + * that is all most of them need: it is registered by `PlatformObjectsPlugin` + * — platform infrastructure, present with or without any optional service + * (#4243). `os migrate value-shapes` boots exactly that. * - * ⚠️ `sys_migration` is registered by the STORAGE plugin (its first consumer), - * which is why a migration with nothing to do with files still boots it. That - * coupling is not this module's to fix — the ledger is platform infrastructure - * and should not be owned by an optional service — but until it moves, every - * gated migration boots the same set so they all find the same ledger. Tracked - * separately; `os serve` auto-wires storage, so a served deployment always has - * it registered and the runtime gates can read their flags. + * Only the FILE migration (`os migrate files-to-references`) also needs + * `sys_file` plus the deployment's REAL storage adapter — it reconciles what + * records claim against what storage actually holds, so a root that disagrees + * with the server's reconciles against the wrong tree. `storage: true` adds: + * + * - Settings first: the storage plugin re-resolves its adapter from + * persisted settings when a settings service is present, which is how an + * S3-configured deployment's backfill uploads land in S3 rather than on + * this machine. + * - Storage config through the SAME resolver `os serve` uses + * (`resolveStorageCapabilityArg`), so the CLI materialises bytes exactly + * where the server would. */ -export async function buildDataMigrationPlugins(): Promise { +export async function buildDataMigrationPlugins( + opts: { storage?: boolean } = {}, +): Promise { const plugins: unknown[] = []; - try { - const { SettingsServicePlugin } = await import('@objectstack/service-settings'); - plugins.push(new SettingsServicePlugin({ registerRoutes: false })); - } catch { - // optional — without it, constructor/env-driven storage config still applies + const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); + plugins.push(new PlatformObjectsPlugin()); + if (opts.storage === true) { + try { + const { SettingsServicePlugin } = await import('@objectstack/service-settings'); + plugins.push(new SettingsServicePlugin({ registerRoutes: false })); + } catch { + // optional — without it, constructor/env-driven storage config still applies + } + const { StorageServicePlugin } = await import('@objectstack/service-storage'); + const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT); + plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false })); } - const { StorageServicePlugin } = await import('@objectstack/service-storage'); - const { options } = resolveStorageCapabilityArg(process.env.OS_STORAGE_ROOT); - plugins.push(new StorageServicePlugin({ ...options, registerRoutes: false })); return plugins; } diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts new file mode 100644 index 0000000000..892d94f025 --- /dev/null +++ b/packages/platform-objects/src/plugin.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { PlatformObjectsPlugin } from './plugin.js'; +import { SysMigration } from './system/index.js'; + +/** + * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this + * package has no kernel dependency and the plugin is structurally typed, so + * booting a real kernel here would buy nothing. + */ +function makeCtx() { + const services = new Map(); + const hooks: Array<() => Promise | void> = []; + const logs: { info: string[]; warn: string[] } = { info: [], warn: [] }; + const ctx: any = { + logger: { + info: (m: string) => { logs.info.push(String(m)); }, + warn: (m: string) => { logs.warn.push(String(m)); }, + }, + _logs: logs, + registerService: (name: string, svc: any) => { services.set(name, svc); }, + getService: (name: string): T => { + const s = services.get(name); + if (!s) throw new Error(`service '${name}' not registered`); + return s as T; + }, + hook: (event: string, fn: () => Promise | void) => { + if (event === 'kernel:ready') hooks.push(fn); + }, + _flushReady: async () => { for (const h of hooks) await h(); }, + }; + return ctx; +} + +describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () => { + it('registers SysMigration through the manifest service', async () => { + const ctx = makeCtx(); + const manifests: any[] = []; + ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); + + await new PlatformObjectsPlugin().init(ctx); + + expect(manifests).toHaveLength(1); + expect(manifests[0].id).toBe('com.objectstack.platform-objects'); + expect(manifests[0].scope).toBe('system'); + expect(manifests[0].objects).toEqual([SysMigration]); + expect(manifests[0].objects[0].name).toBe('sys_migration'); + }); + + it('degrades silently without a manifest service (lean/i18n-only kernels)', async () => { + const plugin = new PlatformObjectsPlugin(); + const ctx = makeCtx(); + + await expect(plugin.init(ctx)).resolves.toBeUndefined(); + await plugin.start(ctx); + await expect(ctx._flushReady()).resolves.toBeUndefined(); + }); +}); + +describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', () => { + /** Engine fake with a `sys_migration` table and a settable newness verdict. */ + function engineWith(createdFromEmpty: boolean | undefined) { + const rows: Array> = []; + const engine: any = { + getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined), + find: async (_object: string, options: any) => + rows.filter((r) => options?.where?.id === undefined || r.id === options.where.id), + insert: async (_object: string, data: any) => { + rows.push({ ...data }); + return data; + }, + update: async () => ({}), + rows, + }; + if (createdFromEmpty !== undefined) { + engine.wasDatastoreCreatedFromEmpty = () => createdFromEmpty; + } + return engine; + } + + async function boot(engine: unknown) { + const plugin = new PlatformObjectsPlugin(); + const ctx = makeCtx(); + ctx.registerService('objectql', engine); + await plugin.init(ctx); + await plugin.start(ctx); + await ctx._flushReady(); + } + + it('attests a store this boot created from empty', async () => { + const engine = engineWith(true); + + await boot(engine); + + expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ + 'adr-0104-file-references', + 'adr-0104-value-shapes', + ]); + for (const row of engine.rows) { + expect(row.verified_at).toBeTruthy(); + expect(row.blocking).toBe(0); + } + }); + + /** + * The property the whole design rests on: a store that was FOUND — an + * upgrade, a restart, anything with history — attests nothing and keeps + * producing its evidence by scan. + */ + it('attests nothing on a store that already existed', async () => { + const engine = engineWith(false); + + await boot(engine); + + expect(engine.rows).toHaveLength(0); + }); + + it('attests nothing when the engine cannot say (older engine)', async () => { + const engine = engineWith(undefined); + + await boot(engine); + + expect(engine.rows).toHaveLength(0); + }); + + it('a failing attestation never breaks the boot', async () => { + const engine = engineWith(true); + engine.insert = async () => { + throw new Error('disk full'); + }; + + await expect(boot(engine)).resolves.toBeUndefined(); + }); + + it('invalidates the engine memo after attesting (fast-boot race)', async () => { + const engine = engineWith(true); + let invalidated = 0; + engine.invalidateDataMigrationFlags = () => { invalidated++; }; + + await boot(engine); + + expect(invalidated).toBe(1); + }); +}); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 58b9f4452b..822f90f719 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -2,26 +2,37 @@ import { SetupAppTranslations } from './apps/translations/index.js'; import { MetadataFormsTranslations } from './metadata-translations/index.js'; +import { SysMigration } from './system/sys-migration.object.js'; +import { attestFreshDatastore } from './system/migration-flag.js'; /** * `PlatformObjectsPlugin` * - * Owns the runtime contribution of platform-default translation bundles - * into the kernel's i18n service. Replaces the historical detour of - * piggy-backing on `plugin-auth` to load these bundles — translations - * belong with the package that defines them. + * Owns the runtime contribution of platform infrastructure that must exist + * on every assembled kernel, independent of which optional services are + * composed: * - * Loaded on `kernel:ready` so the i18n service plugin (which may register - * later than ordinary services) is guaranteed to be present. + * - **`sys_migration`** — the deployment-level data-migration flag ledger + * (#3617). Registered here rather than by a consuming service because + * its consumers span domains (#4243): the storage service's released- + * file collection, the engine's strict value-shape gates (#3438/#4235) + * and the migration CLI all read the same ledger, and none of them + * should have to drag an unrelated service into the boot to find it. + * - **Fresh-datastore attestation** (#3438, ADR-0104 2026-07-30) — + * travels with the ledger registration: a store this boot created from + * empty is attested at `kernel:ready`, whichever services are composed. + * - **Translation bundles** — `SetupAppTranslations` (the static Setup + * App + sys_* dashboards) and `MetadataFormsTranslations` + * (`metadataForms.*` for object/field/agent/flow/view configuration + * forms). Replaces the historical detour of piggy-backing on + * `plugin-auth` — translations belong with the package that defines + * them. Loaded on `kernel:ready` so the i18n service plugin (which may + * register later than ordinary services) is guaranteed to be present. * - * Bundles contributed: - * - `SetupAppTranslations` — the static Setup App + sys_* dashboards. - * - `MetadataFormsTranslations` — `metadataForms.*` for object/field/ - * agent/flow/view configuration forms. - * - * Gracefully degrades when no i18n service is registered (e.g. lean - * test / MSW kernels): the UI then falls back to the inline literals - * carried on each App / Dashboard / `*.form.ts` schema. + * Gracefully degrades on lean kernels: without a manifest service the + * ledger is simply absent — every flag reader answers "not verified", the + * safe posture — and without an i18n service the UI falls back to the + * inline literals carried on each App / Dashboard / `*.form.ts` schema. * * Structurally typed against `@objectstack/core`'s `Plugin` contract so * this package does not need to depend on the kernel at compile time. @@ -31,13 +42,69 @@ export class PlatformObjectsPlugin { readonly type = 'standard'; readonly version = '1.0.0'; readonly dependencies: string[] = []; + /** + * The manifest service consumed in `init()` is registered by ObjectQL's + * init. Optional, not required: hoisted after it when both are composed, + * still bootable without it (translations-only kernels). + */ + readonly optionalDependencies: string[] = ['com.objectstack.engine.objectql']; - async init(_ctx: any): Promise { - // No-op: this plugin only contributes translations on `kernel:ready`. - // The init hook exists to satisfy the kernel's Plugin contract. + async init(ctx: any): Promise { + // Register the platform-infrastructure objects via the manifest service. + try { + ctx?.getService?.('manifest')?.register?.({ + id: this.name, + name: 'Platform Objects', + version: this.version, + type: 'plugin', + scope: 'system', + objects: [SysMigration], + }); + } catch { + // No manifest service (lean / i18n-only kernels) — the ledger stays + // unregistered and every flag reader answers "not verified". + } } async start(ctx: any): Promise { + // ── Fresh-datastore attestation (#3438, ADR-0104 2026-07-30) ──────── + // A store this process just created from empty can hold no legacy + // value, so the data migrations that exist to find and convert them + // are settled here before they are ever run — recorded now, while that + // emptiness is still an observed fact rather than something a later + // scan would have to infer. Without it every new deployment would + // start lax and stay lax until someone ran a command that, for them, + // does nothing: the warn regime would never die out. + // + // This plugin owns the call because it registers `sys_migration` + // (above; #4243 — moved here with the registration from + // service-storage). A store that was found rather than created attests + // nothing and keeps producing evidence by scan. + ctx?.hook?.('kernel:ready', async () => { + let engine: any; + try { + engine = ctx.getService?.('objectql'); + } catch { + return; + } + if (!engine || typeof engine.wasDatastoreCreatedFromEmpty !== 'function') return; + try { + if (engine.wasDatastoreCreatedFromEmpty()) { + await attestFreshDatastore(engine, { logger: ctx.logger }); + // The engine memoizes the flag read on first use; this write + // may already have raced it on a fast boot. + engine.invalidateDataMigrationFlags?.(); + } + } catch (err: any) { + // Bookkeeping must never break a new deployment's boot. Not + // attesting only leaves it lax — recoverable by running the + // migration, which for an empty store is a no-op that passes. + ctx?.logger?.warn?.( + `[platform-objects] fresh-datastore attestation skipped (${err?.message ?? err})`, + ); + } + }); + ctx?.hook?.('kernel:ready', async () => { let i18n: any; try { diff --git a/packages/platform-objects/src/system/sys-migration.object.ts b/packages/platform-objects/src/system/sys-migration.object.ts index bc9af0e0c9..6d6d644f66 100644 --- a/packages/platform-objects/src/system/sys-migration.object.ts +++ b/packages/platform-objects/src/system/sys-migration.object.ts @@ -24,9 +24,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Writes flow through `recordDataMigrationRun` (system context, from the * migration commands) — the API surface is read-only diagnostics. * - * Registered by the first consuming service (`@objectstack/service-storage`); - * the row contract lives in `@objectstack/spec/system` (`DataMigrationFlagSchema`) - * so any package can read flags without depending on this one. + * Registered by `PlatformObjectsPlugin` (`./plugin.ts`) — the ledger is + * platform infrastructure, present on every kernel that composes the platform + * objects, not owned by any one consuming service (#4243; it was originally + * registered by its first consumer, `@objectstack/service-storage`, until the + * storage-independent consumers of #4215/#4235 arrived). The row contract + * lives in `@objectstack/spec/system` (`DataMigrationFlagSchema`) so any + * package can read flags without depending on this one. * * @namespace sys */ diff --git a/packages/services/service-storage/src/storage-service-plugin.test.ts b/packages/services/service-storage/src/storage-service-plugin.test.ts index 307d195b33..d09ea0d952 100644 --- a/packages/services/service-storage/src/storage-service-plugin.test.ts +++ b/packages/services/service-storage/src/storage-service-plugin.test.ts @@ -371,82 +371,6 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () => }); }); -describe('StorageServicePlugin: fresh-datastore attestation (#3438, ADR-0104)', () => { - /** Engine fake with a `sys_migration` table and a settable newness verdict. */ - function engineWith(createdFromEmpty: boolean | undefined) { - const rows: Array> = []; - const engine: any = { - registerHook: () => {}, - registerMiddleware: () => {}, - getObject: (name: string) => (name === 'sys_migration' ? { name } : undefined), - find: async (_object: string, options: any) => - rows.filter((r) => options?.where?.id === undefined || r.id === options.where.id), - findOne: async () => null, - insert: async (_object: string, data: any) => { - rows.push({ ...data }); - return data; - }, - update: async () => ({}), - rows, - }; - if (createdFromEmpty !== undefined) { - engine.wasDatastoreCreatedFromEmpty = () => createdFromEmpty; - } - return engine; - } - - async function boot(engine: unknown) { - const dir = await fs.mkdtemp(join(tmpdir(), 'oss-attest-')); - const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir: dir }, registerRoutes: false }); - const ctx = makeCtx(); - ctx.registerService('objectql', engine); - await plugin.init(ctx); - await plugin.start(ctx); - await ctx._flushReady(); - } - - it('attests a store this boot created from empty', async () => { - const engine = engineWith(true); - - await boot(engine); - - expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ - 'adr-0104-file-references', - 'adr-0104-value-shapes', - ]); - for (const row of engine.rows) { - expect(row.verified_at).toBeTruthy(); - expect(row.blocking).toBe(0); - } - }); - - /** - * The property the whole design rests on: a store that was FOUND — an - * upgrade, a restart, anything with history — attests nothing and keeps - * producing its evidence by scan. - */ - it('attests nothing on a store that already existed', async () => { - const engine = engineWith(false); - - await boot(engine); - - expect(engine.rows).toHaveLength(0); - }); - - it('attests nothing when the engine cannot say (older engine)', async () => { - const engine = engineWith(undefined); - - await boot(engine); - - expect(engine.rows).toHaveLength(0); - }); - - it('a failing attestation never breaks the boot', async () => { - const engine = engineWith(true); - engine.insert = async () => { - throw new Error('disk full'); - }; - - await expect(boot(engine)).resolves.toBeUndefined(); - }); -}); +// Fresh-datastore attestation (#3438, ADR-0104) moved to PlatformObjectsPlugin +// with the sys_migration registration it belongs to (#4243) — its tests live in +// @objectstack/platform-objects (src/plugin.test.ts). diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index cc2703437b..2b96cde231 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -29,12 +29,12 @@ import { SystemFile, SystemUploadSession } from './objects/index.js'; // storage domain, not the audit/compliance ledger. Definition stays in // platform-objects; storage now contributes (registers) it instead of audit. import { SysAttachment } from '@objectstack/platform-objects/audit'; -// Deployment-level data-migration flags (#3617). The table is platform-generic -// (defined in platform-objects, contract in spec/system), registered here by -// its first consuming domain: the ADR-0104 file-as-reference row gates this -// service's released-file collection (#3459 PR-5b) and the strict media -// value-shape default (#3438). -import { SysMigration, isDataMigrationVerified, attestFreshDatastore } from '@objectstack/platform-objects/system'; +// Deployment-level data-migration flag READER (#3617). The `sys_migration` +// ledger itself is platform infrastructure — registered by +// `PlatformObjectsPlugin` (#4243), not by this service — this service only +// consumes the ADR-0104 file-as-reference flag, which gates its released-file +// collection (#3459 PR-5b). +import { isDataMigrationVerified } from '@objectstack/platform-objects/system'; import { FILE_REFERENCES_MIGRATION_ID } from '@objectstack/spec/system'; import { SwappableStorageService } from './swappable-storage-service.js'; import { @@ -240,7 +240,7 @@ export class StorageServicePlugin implements Plugin { version: '1.0.0', type: 'plugin', scope: 'system', - objects: [SystemFile, SystemUploadSession, SysAttachment, SysMigration], + objects: [SystemFile, SystemUploadSession, SysAttachment], }); } catch { // manifest service may not be available in all environments @@ -334,36 +334,10 @@ export class StorageServicePlugin implements Plugin { // hooks above, and nothing sweeps without the LifecycleService. } - // ── Fresh-datastore attestation (#3438, ADR-0104 2026-07-30) ─── - // A store this process just created from empty can hold no legacy - // value, so the data migrations that exist to find and convert them - // are settled here before they are ever run — recorded now, while - // that emptiness is still an observed fact rather than something a - // later scan would have to infer. Without it every new deployment - // would start lax and stay lax until someone ran a command that, for - // them, does nothing: the warn regime would never die out. - // - // This service owns the call because it registers `sys_migration` - // (above) and is the platform's only holder of the flag's other - // consumers. A store that was found rather than created attests - // nothing and keeps producing evidence by scan. - if (typeof (engine as any).wasDatastoreCreatedFromEmpty === 'function') { - try { - if ((engine as any).wasDatastoreCreatedFromEmpty()) { - await attestFreshDatastore(engine as any, { logger: ctx.logger }); - // The engine memoizes the flag read on first use; this write - // may already have raced it on a fast boot. - (engine as any).invalidateDataMigrationFlags?.(); - } - } catch (err) { - // Bookkeeping must never break a new deployment's boot. Not - // attesting only leaves it lax — recoverable by running the - // migration, which for an empty store is a no-op that passes. - ctx.logger.warn( - `StorageServicePlugin: fresh-datastore attestation skipped (${(err as Error)?.message ?? err})`, - ); - } - } + // Fresh-datastore attestation (#3438, ADR-0104) moved to + // `PlatformObjectsPlugin` with the `sys_migration` registration it + // belongs to (#4243) — the ledger and its bookkeeping are platform + // infrastructure, present with or without this service. } // ── HTTP routes (existing behaviour) ───────────────────────────