diff --git a/.changeset/defer-adr0104-attestation-while-seed-in-flight.md b/.changeset/defer-adr0104-attestation-while-seed-in-flight.md new file mode 100644 index 0000000000..5a7be2082f --- /dev/null +++ b/.changeset/defer-adr0104-attestation-while-seed-in-flight.md @@ -0,0 +1,18 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/runtime': patch +'@objectstack/objectql': patch +'@objectstack/spec': patch +--- + +fix(platform-objects): 超预算后台 seed 期间不再空库自证 —— 一次启动不再跑两套契约 + +#4769 已把 ADR-0104 的空库自证从 `kernel:ready` 挪到 `app:seeded`(本次启动自身数据的结算点),但保留 `kernel:ready` 作为「从不 seed 的内核」的兜底。剩下的窗口是这两个钩子**到达顺序可以颠倒**:`AppPlugin` 的 inline seed 超出软预算(`OS_INLINE_SEED_BUDGET_MS`,默认 8s)后转入后台,于是 `kernel:ready` 先到、兜底自证在 seed 仍在写的时候签发证书并把闸门翻到 strict——同一次 seed 运行的后半段撞上前半段从未见过的契约。showcase 冷启(`OS_INLINE_SEED_BUDGET_MS=1`)实测:自证发生在 +0.470s,seed 结算在 +3.617s,窗口 3.147s。 + +现在两个钩子都先问一句「本次启动自己的 seed 落定了吗」,任一处报告仍有未结算的 seed 源就不签发。`app:seeded` 同样受这道检查约束——多 config app 的 bundle 会每个 app 触发一次,第一次并不是本次启动的结算点。 + +新增 `seed-settlement` 契约(`@objectstack/spec/contracts`)承载这个信号,而不是让 platform-objects 去嗅 runtime 内部的 `seed-datasets` 服务:那个数组的存在只能说明「seed 源存在」,永远说明不了「已经落定」,而这两件事之间的差正是本 bug 的整个窗口。runtime 在选择分支之前先声明 seed 源,并在写入真正结束的同一刻结算它。 + +**multi-tenant 与 `skipSeedData` 的 ADR-0104 姿态(2026-08-06 裁定,#4795)**:这两种部署会注册 seed 数据但在启动时并不写入(前者按 org 在 `sys_organization` insert 时重放,后者是 `os migrate` 的只读规划启动,#3917),`app:seeded` 永不触发。它们的姿态是**启动时不自证,等 `os migrate … --apply` 在真实扫描的证据上落笔**——由同一个判据自然得出,不需要单独分支。这是答案而不是缺口:在启动那一刻断言一次尚未发生的 per-org 重放不含违规值,正是 #4769 的同一个错误、只是引信更长;而停在 warn-first 是可恢复的方向,随时可由 `os migrate value-shapes --apply` / `os migrate files-to-references --apply` 关闭。 + +`@objectstack/objectql` 侧只更新了 #4769 撤销机制的注释:「后台 seed 收尾晚于签发」不再是它要兜的场景(已在源头关闭),它对 `os dev` 热重载 seeder、运行期 marketplace 安装以及 lax 开关仍然有效。 diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1b0ce88a85..19b70620e9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3952,10 +3952,20 @@ export class ObjectQL implements IObjectQLEngine { * order — the certificate is already in the ledger and the contradicting * value lands afterwards, which is reachable whenever the deployment is * still lenient at that moment (`OS_ALLOW_LAX_MEDIA_VALUES` / - * `OS_ALLOW_LAX_VALUE_SHAPES`, or a seed that finishes in the background - * after its budget). Without this the ledger would keep asserting a fact the - * store contradicts, and the NEXT boot would enforce it against exactly the - * data this one wrote. + * `OS_ALLOW_LAX_VALUE_SHAPES`) or whenever a writer runs after the + * attestation point at all — the `os dev` hot-reload seeder and a runtime + * marketplace install both seed on a store this boot created. Without this + * the ledger would keep asserting a fact the store contradicts, and the NEXT + * boot would enforce it against exactly the data this one wrote. + * + * The boot's own inline seed used to head that list, via the background + * continuation of a run that overran `OS_INLINE_SEED_BUDGET_MS` — the + * attestation's `kernel:ready` backstop fired mid-seed and the tail landed + * against the certificate it had just issued. #4795 closed that ordering at + * the source: the attestation now defers while the `seed-settlement` contract + * reports a source outstanding, so the inline seed can no longer contradict + * a certificate this boot issued. This stays the safety net rather than the + * first line of defence for it. * * Deliberately narrow: * diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 77e6ea264b..99936f1877 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -263,4 +263,170 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)', expect(engine.rows.map((r: any) => r.id)).toEqual(['adr-0104-value-shapes']); }); }); + + /** + * #4795 — `app:seeded` and `kernel:ready` can arrive in EITHER order. + * + * When the inline seed overruns `OS_INLINE_SEED_BUDGET_MS` the runtime hands + * it to the background, so `kernel:ready` lands first and the #4769 backstop + * fired mid-seed: it certified the store, flipped the gates to strict, and + * the tail of the same seed run met a contract its head never saw — one + * boot, two contracts. Measured on a showcase cold boot at + * `OS_INLINE_SEED_BUDGET_MS=1`: attestation +0.470s, seed settled +3.617s. + * + * The fix asks the published `seed-settlement` contract instead of guessing + * from the runtime's internal `seed-datasets` array — see the contract's own + * TSDoc for why an array's presence cannot answer this. + */ + describe('defers while this boot own seed is still landing (#4795)', () => { + /** Fake `seed-settlement` service over mutable state a test can advance. */ + function seedSettlementFake(state: { + inFlight?: number; + suppressed?: Array<'multi-tenant-replay' | 'skip-seed-data'>; + }) { + return { + snapshot: () => ({ + pending: (state.inFlight ?? 0) + (state.suppressed?.length ?? 0), + inFlight: state.inFlight ?? 0, + suppressed: [...(state.suppressed ?? [])], + }), + }; + } + + async function bootWithSeed(engine: unknown, state: Parameters[0]) { + const plugin = new PlatformObjectsPlugin(); + const ctx = makeCtx(); + ctx.registerService('objectql', engine); + ctx.registerService('seed-settlement', seedSettlementFake(state)); + await plugin.init(ctx); + await plugin.start(ctx); + return ctx; + } + + /** + * The nail for this issue: at `kernel:ready` the background seed is still + * writing, so nothing may be certified yet — and once it settles, the + * certificate lands normally. One contract for the whole seed run. + */ + it('kernel:ready writes no attestation while a seed source is still in flight', async () => { + const engine = engineWith(true); + const state = { inFlight: 1 }; + + const ctx = await bootWithSeed(engine, state); + await ctx._flushReady(); + + expect(engine.rows).toHaveLength(0); + + // The background seed finishes and the runtime emits `app:seeded`. + state.inFlight = 0; + await ctx._flush('app:seeded'); + + expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ + 'adr-0104-file-references', + 'adr-0104-value-shapes', + ]); + }); + + /** + * `app:seeded` fires once per config app, so the FIRST one is not the + * settle point for the boot. Guarding only `kernel:ready` would move the + * same split-contract window onto multi-app bundles. + */ + it('an app:seeded from one config app does not certify while another is still writing', async () => { + const engine = engineWith(true); + const state = { inFlight: 2 }; + + const ctx = await bootWithSeed(engine, state); + + state.inFlight = 1; // app A settled; app B still writing + await ctx._flush('app:seeded'); + expect(engine.rows).toHaveLength(0); + + state.inFlight = 0; // app B settled + await ctx._flush('app:seeded'); + expect(engine.rows).toHaveLength(2); + }); + + /** + * The #4795 ruling (2026-08-06), pinned: a deployment whose seed never runs + * at boot does not self-certify — it waits for `os migrate`. Falls out of + * the same predicate rather than needing a branch of its own. + */ + it.each([ + ['multi-tenant', 'multi-tenant-replay' as const], + ['skipSeedData', 'skip-seed-data' as const], + ])('%s: attests nothing at boot, without erroring', async (_label, reason) => { + const engine = engineWith(true); + + const ctx = await bootWithSeed(engine, { suppressed: [reason] }); + await expect(ctx._flushReady()).resolves.toBeUndefined(); + + expect(engine.rows).toHaveLength(0); + }); + + it('says why it stood down, and names the command that closes the gate', async () => { + const engine = engineWith(true); + + const ctx = await bootWithSeed(engine, { suppressed: ['multi-tenant-replay'] }); + await ctx._flushReady(); + + const said = ctx._logs.info.join('\n'); + expect(said).toContain('multi-tenant-replay'); + expect(said).toContain('os migrate value-shapes --apply'); + // A posture that is correct by design must not spend the level that + // means "something you trusted did not persist" (AGENTS.md). + expect(ctx._logs.warn.join('\n')).not.toContain('not attesting'); + }); + + it('an in-flight deferral says it will be picked up on app:seeded', async () => { + const engine = engineWith(true); + + const ctx = await bootWithSeed(engine, { inFlight: 1 }); + await ctx._flushReady(); + + expect(ctx._logs.info.join('\n')).toContain('app:seeded'); + }); + + /** + * Regression guard for the two paths this change must leave untouched: + * a seed that fits inside its budget (settled before `kernel:ready`), and + * a kernel with no seed pipeline at all — the backstop #4769 kept for + * exactly that case, which is the same moment as before for it. + */ + it('a settled seed attests at kernel:ready exactly as before', async () => { + const engine = engineWith(true); + + const ctx = await bootWithSeed(engine, { inFlight: 0 }); + await ctx._flushReady(); + + expect(engine.rows.map((r: any) => r.id).sort()).toEqual([ + 'adr-0104-file-references', + 'adr-0104-value-shapes', + ]); + }); + + it('a kernel with no seed pipeline still attests on the kernel:ready backstop', async () => { + const engine = engineWith(true); + const plugin = new PlatformObjectsPlugin(); + const ctx = makeCtx(); + ctx.registerService('objectql', engine); // no `seed-settlement` service + await plugin.init(ctx); + await plugin.start(ctx); + + await ctx._flushReady(); + + expect(engine.rows).toHaveLength(2); + }); + + /** + * A store that was FOUND was never going to be attested, so announcing a + * deferral over it would explain a decision nobody was making. + */ + it('says nothing about deferral on a store that already existed', async () => { + const ctx = await bootWithSeed(engineWith(false), { inFlight: 1 }); + await ctx._flushReady(); + + expect(ctx._logs.info.join('\n')).not.toContain('attestation deferred'); + }); + }); }); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 490a50810e..4a25b10659 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -6,7 +6,13 @@ import { SysMigration } from './system/sys-migration.object.js'; import { SysMigrationJournal } from './system/sys-migration-journal.object.js'; import { SysSecret } from './system/sys-secret.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; -import type { II18nService, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { + II18nService, + IObjectQLEngine, + ISeedSettlementService, + SeedSettlementSnapshot, +} from '@objectstack/spec/contracts'; +import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; /** @@ -48,7 +54,10 @@ import type { II18nService, IObjectQLEngine } from '@objectstack/spec/contracts' * seed), whichever services are composed. Not before: emptiness settles * a claim about CONTENT, and a boot that certifies itself and then seeds * rows contradicting the certificate leaves every later boot enforcing - * it against data this one wrote (#4769). + * it against data this one wrote (#4769). The `kernel:ready` fallback + * additionally asks the `seed-settlement` contract whether a seed is + * still in flight, so an over-budget background seed is waited out + * rather than certified over (#4795). * - **Translation bundles** — `SetupAppTranslations` (the static Setup * App + sys_* dashboards) and `MetadataFormsTranslations` * (`metadataForms.*` for object/field/agent/flow/view configuration @@ -116,7 +125,26 @@ export class PlatformObjectsPlugin { // (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. - const attest = async () => { + // #4795 — "has this boot's own seed finished landing?", asked through the + // published `seed-settlement` contract rather than by sniffing the + // runtime's internal `seed-datasets` service. That array's presence says a + // seed source EXISTS; it can never say whether it has SETTLED, and the gap + // between those two facts IS the bug. An absent service means no seed + // pipeline registered on this kernel — a fact by `kernel:ready`, since + // every source is declared in Phase 2 `start()`. + const readSeedSettlement = (): SeedSettlementSnapshot | undefined => { + try { + const svc = ctx.getService?.(SEED_SETTLEMENT_SERVICE) as + | ISeedSettlementService + | undefined; + if (!svc || typeof svc.snapshot !== 'function') return undefined; + return svc.snapshot(); + } catch { + return undefined; + } + }; + + const attest = async (phase: 'app:seeded' | 'kernel:ready') => { let engine: IObjectQLEngine | undefined; try { engine = ctx.getService?.('objectql'); @@ -126,6 +154,15 @@ export class PlatformObjectsPlugin { if (!engine || typeof engine.wasDatastoreCreatedFromEmpty !== 'function') return; try { if (engine.wasDatastoreCreatedFromEmpty()) { + // Asked AFTER the created-from-empty check on purpose: a store that + // was found rather than created attests nothing either way, and + // announcing a deferral there would be noise about a decision that + // was never going to be made. + const seed = readSeedSettlement(); + if (seed && seed.pending > 0) { + if (phase === 'kernel:ready') reportDeferral(ctx, seed); + return; + } 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. @@ -151,8 +188,20 @@ export class PlatformObjectsPlugin { // (it is the same moment as before for those). Both land in the same // idempotent call: the first one to find an id unattested and // uncontradicted writes it, the other finds the row and skips. - ctx?.hook?.('app:seeded', attest); - ctx?.hook?.('kernel:ready', attest); + // + // #4795 — subscribing to both is necessary but not sufficient, because the + // two can arrive in EITHER order. When the inline seed overruns its budget + // the runtime hands it to the background and `kernel:ready` arrives first, + // so the backstop fired mid-seed: it certified the store, flipped the gates + // to strict, and the tail of the same seed run met a contract its head had + // never seen — one boot, two contracts. Measured on a showcase cold boot at + // `OS_INLINE_SEED_BUDGET_MS=1`: attestation +0.470s, seed settled +3.617s. + // Neither hook may certify while the pipeline reports work outstanding, so + // the settlement check lives inside `attest` and guards both — `app:seeded` + // included, since a bundle with several config apps fires it once per app + // and the first one is not the last. + ctx?.hook?.('app:seeded', () => attest('app:seeded')); + ctx?.hook?.('kernel:ready', () => attest('kernel:ready')); ctx?.hook?.('kernel:ready', async () => { let i18n: II18nService | undefined; @@ -194,6 +243,62 @@ export class PlatformObjectsPlugin { } } +/** + * Say, once, why `kernel:ready` did not attest — and what closes the gate. + * + * ## The ADR-0104 posture for deployments that never settle a boot seed (#4795) + * + * Two shapes register seed datasets and deliberately do not run them at boot, + * so `app:seeded` never fires and the tally stays pending for the life of the + * process: **multi-tenant** (seeds replay per organization on + * `sys_organization` insert) and **`skipSeedData`** (an `os migrate` planning + * boot that must not write to the target database at all, #3917). + * + * Their posture is **do not self-certify at boot; wait for `os migrate`** — + * ruled 2026-08-06 and recorded on #4795. It is not a gap this check leaves + * behind, it is the answer: + * + * - the fresh-datastore attestation infers "created empty, therefore no + * legacy value can exist". On a multi-tenant deployment the rows that + * inference is about have not been written yet — they land org by org, + * later. Certifying at startup that a replay which has not happened holds + * no violating value is exactly #4769's error with a longer fuse; + * - a `skipSeedData` boot writes nothing, so it observes nothing, so it has + * no evidence to certify from; + * - standing down is the *recoverable* direction. The deployment stays + * warn-first — true, and closable at any time by `os migrate value-shapes + * --apply` / `os migrate files-to-references --apply`, which record the flag + * on a real scan of what the store actually holds. The opposite error is not + * recoverable in the same way: a certificate issued over rows nobody looked + * at is enforced by every later boot against data it never examined. + * + * Logged at `info`, not `warn`. This is a functional posture, not a durability + * degradation: nothing claims to have persisted and failed, and the gate that + * stays open is the lenient one. A `warn` on every boot of every multi-tenant + * deployment for behaviour that is correct by design is precisely what trains + * operators to skim the level that matters (AGENTS.md, degradation log levels). + */ +function reportDeferral(ctx: any, seed: SeedSettlementSnapshot): void { + if (seed.suppressed.length > 0) { + const reasons = [...new Set(seed.suppressed)].join(', '); + ctx?.logger?.info?.( + `[platform-objects] not attesting this fresh datastore at boot: seed data is registered but ` + + `this boot does not write it (${reasons}). Multi-tenant deployments replay seeds per org on ` + + `sys_organization insert, and a skipSeedData boot writes nothing at all — so nothing observed ` + + `now could prove or disprove the claim. The deployment stays warn-first until ` + + `\`os migrate value-shapes --apply\` / \`os migrate files-to-references --apply\` records the ` + + `flag on a real scan (ADR-0104, #4795).`, + ); + return; + } + ctx?.logger?.info?.( + `[platform-objects] fresh-datastore attestation deferred at kernel:ready: ${seed.inFlight} seed ` + + `source(s) still writing (an inline seed overran OS_INLINE_SEED_BUDGET_MS and continues in the ` + + `background). Attesting now would flip this boot to strict half-way through its own seed run. ` + + `It runs on \`app:seeded\` once the seed settles (ADR-0104, #4795).`, + ); +} + /** Convenience factory mirroring the rest of the plugin ecosystem. */ export function createPlatformObjectsPlugin(): PlatformObjectsPlugin { return new PlatformObjectsPlugin(); diff --git a/packages/runtime/src/app-plugin.seed.test.ts b/packages/runtime/src/app-plugin.seed.test.ts index ad443b9a79..dd058db099 100644 --- a/packages/runtime/src/app-plugin.seed.test.ts +++ b/packages/runtime/src/app-plugin.seed.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AppPlugin } from './app-plugin'; +import { readSeedSettlement } from './seed-settlement.js'; import type { PluginContext } from '@objectstack/core'; /** @@ -122,4 +123,114 @@ describe('AppPlugin inline-seed settle signal (app:seeded, #2996)', () => { expect(insert).not.toHaveBeenCalled(); expect(trigger).not.toHaveBeenCalledWith('app:seeded', expect.anything()); }); + + /** + * #4795 — the same three branches, seen through the `seed-settlement` + * contract. `app:seeded` alone cannot answer "is a seed still landing?" + * because the two branches that never emit it are exactly the two where + * the answer is "yes, and it never will be" — so the settle signal needs a + * standing tally beside it, declared before the branch is chosen. + */ + describe('seed-settlement signal (#4795)', () => { + /** Like `makeContext`, but with a service map the tally can live in. */ + const makeSettlementContext = (): PluginContext => { + const services = new Map(); + return makeContext({ + registerService: vi.fn((name: string, svc: unknown) => { + if (services.has(name)) throw new Error(`service '${name}' already registered`); + services.set(name, svc); + }), + getService: vi.fn((name: string) => { + if (name === 'objectql') return { insert }; + if (services.has(name)) return services.get(name); + return undefined; // `metadata` absent → basic-insert fallback + }), + } as unknown as Partial); + }; + + it('an over-budget seed stays pending until the background run settles', async () => { + process.env.OS_INLINE_SEED_BUDGET_MS = '10'; + insert = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 60))); + const ctx = makeSettlementContext(); + + await new AppPlugin(bundleWithUser()).start(ctx); + + // start() returned on the budget; the rows are still landing. This + // is the window in which `kernel:ready` used to certify the store. + expect(readSeedSettlement(ctx)).toEqual({ pending: 1, inFlight: 1, suppressed: [] }); + + await vi.waitFor(() => { + expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] }); + }); + expect(trigger).toHaveBeenCalledWith('app:seeded', { + appId: 'seed-test-app', + overBudget: true, + }); + }); + + it('a seed that fits its budget has already settled when start() returns', async () => { + process.env.OS_INLINE_SEED_BUDGET_MS = '8000'; + insert = vi.fn(async () => undefined); + const ctx = makeSettlementContext(); + + await new AppPlugin(bundleWithUser()).start(ctx); + + expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] }); + }); + + it('multi-tenant suppresses its source, which therefore never settles', async () => { + process.env.OS_MULTI_ORG_ENABLED = 'true'; + insert = vi.fn(async () => undefined); + const ctx = makeSettlementContext(); + + await new AppPlugin(bundleWithUser()).start(ctx); + + expect(readSeedSettlement(ctx)).toEqual({ + pending: 1, + inFlight: 0, + suppressed: ['multi-tenant-replay'], + }); + }); + + it('skipSeedData suppresses its source, which therefore never settles', async () => { + insert = vi.fn(async () => undefined); + const ctx = makeSettlementContext(); + + await new AppPlugin(bundleWithUser(), undefined, { skipSeedData: true }).start(ctx); + + expect(insert).not.toHaveBeenCalled(); + expect(readSeedSettlement(ctx)).toEqual({ + pending: 1, + inFlight: 0, + suppressed: ['skip-seed-data'], + }); + }); + + it('registers no tally at all when the app has no seed datasets', async () => { + insert = vi.fn(async () => undefined); + const ctx = makeSettlementContext(); + + await new AppPlugin({ id: 'seed-test-app' }).start(ctx); + + // Nothing to wait for — the attestation backstop runs unchanged. + expect(readSeedSettlement(ctx)).toBeUndefined(); + }); + + /** + * The settle must land before the `trigger` guard, not after it: a + * kernel context with no `trigger()` still finished its seed, and + * leaving the tally pending there would strand every consumer waiting + * on a signal that cannot arrive. + */ + it('settles even when the kernel context has no trigger()', async () => { + process.env.OS_INLINE_SEED_BUDGET_MS = '8000'; + insert = vi.fn(async () => undefined); + const base = makeSettlementContext(); + const ctx = { ...base, trigger: undefined } as unknown as PluginContext; + + await new AppPlugin(bundleWithUser()).start(ctx); + + expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] }); + }); + }); }); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index dc82c062ff..509396ee36 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -7,6 +7,7 @@ import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/secu import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; +import { declareSeedSource } from './seed-settlement.js'; import { loadDisabledPackageIds } from './package-state-store.js'; import type { IJobService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation'; @@ -1035,6 +1036,15 @@ export class AppPlugin implements Plugin { ctx.logger.warn('[Seeder] Failed to register seed-datasets/seed-replayer service', { error: e?.message }); } + // #4795 — declare this source BEFORE choosing what to do with it. + // Consumers that must not act until the boot's own rows have + // landed (the ADR-0104 fresh-datastore attestation) read the + // tally at `kernel:ready`; counting the source only on the + // branch that seeds would leave exactly the gap they are asking + // about. Every branch below closes the handle — settle when the + // write is done, suppress when this boot will not write at all. + const seedSource = declareSeedSource(ctx); + // Decide whether to also run the seed inline at AppPlugin // start. In multi-tenant mode, the per-org replay (driven // by OrgScopingPlugin's sys_organization middleware) is the @@ -1047,8 +1057,17 @@ export class AppPlugin implements Plugin { if (this.skipSeedData) { // #3917: this boot exists to READ metadata (os migrate // plan/apply). It must not write to the target database. + // The source stays pending for the life of the boot, which + // is what keeps ADR-0104 from self-certifying over rows this + // boot deliberately never wrote (#4795). + seedSource.suppress('skip-seed-data'); ctx.logger.info('[Seeder] skipSeedData — inline seed suppressed; no rows written by this boot'); } else if (multiTenant) { + // Same posture, different cause: the rows these datasets + // describe are written per org on `sys_organization` insert, + // so at boot they do not exist yet and nothing observed now + // can prove a claim about them (#4795). + seedSource.suppress('multi-tenant-replay'); ctx.logger.info('[Seeder] multi-tenant mode — skipping inline seed; per-org replay will run on sys_organization insert'); } else { // Inline seed budget: large bundles (e.g. CRM Starter's 10 @@ -1170,6 +1189,15 @@ export class AppPlugin implements Plugin { // `kernel:ready` — those users would stay member-less until the // next restart. Emitting on settle lets the backfill re-run (#2996). const emitSeedSettled = (overBudget: boolean) => { + // #4795 — settle FIRST, and before the `trigger` guard below. + // Two orderings depend on it: a consumer running inside the + // `app:seeded` hook must see this source already settled (it + // asks "has everything landed?" and would otherwise defer + // forever on the very signal telling it to act), and a kernel + // context with no `trigger()` must not strand the tally at + // pending — the seed still finished, there is simply nobody + // to tell. + seedSource.settle(); const trigger = (ctx as any).trigger; if (typeof trigger !== 'function') return; try { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b69c8a249e..7c7077a131 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -33,6 +33,8 @@ export type { SeedSourceOutcome } from './seed-summary.js'; // contract shared by AppPlugin and the marketplace install path so a new org // replays the UNION of every seed source, not just the first one. export { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; +export { declareSeedSource, readSeedSettlement } from './seed-settlement.js'; +export type { SeedSourceHandle } from './seed-settlement.js'; // External Datasource Federation — boot-validation gate (ADR-0015, Gate 2) export { ExternalValidationPlugin, createExternalValidationPlugin } from './external-validation-plugin.js'; export type { ExternalSchemaDriftEvent } from './external-validation-plugin.js'; diff --git a/packages/runtime/src/seed-settlement.test.ts b/packages/runtime/src/seed-settlement.test.ts new file mode 100644 index 0000000000..c54df1b275 --- /dev/null +++ b/packages/runtime/src/seed-settlement.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { declareSeedSource, readSeedSettlement } from './seed-settlement.js'; + +/** + * #4795 — the settlement tally behind the `seed-settlement` contract. + * + * The consumer that matters is the ADR-0104 fresh-datastore attestation, which + * asks one question at `kernel:ready`: "has every seed source this boot knows + * about finished writing?" Everything below is that question's edge cases. + */ +describe('seed settlement tally (#4795)', () => { + /** Minimal kernel-ish context: a service map with register/get. */ + function makeCtx() { + const services = new Map(); + return { + registerService(name: string, svc: unknown) { + if (services.has(name)) throw new Error(`service '${name}' already registered`); + services.set(name, svc); + }, + getService(name: string) { + if (!services.has(name)) throw new Error(`service '${name}' not registered`); + return services.get(name); + }, + }; + } + + it('reports nothing until a source is declared', () => { + expect(readSeedSettlement(makeCtx())).toBeUndefined(); + }); + + it('a declared source is pending and in flight; settling clears it', () => { + const ctx = makeCtx(); + const source = declareSeedSource(ctx); + + expect(readSeedSettlement(ctx)).toEqual({ pending: 1, inFlight: 1, suppressed: [] }); + + source.settle(); + + expect(readSeedSettlement(ctx)).toEqual({ pending: 0, inFlight: 0, suppressed: [] }); + }); + + /** + * The posture ruled on #4795: a suppressed source stays pending for the + * life of the boot, which is what tells the attestation to stand down + * rather than certify rows this boot never wrote. + */ + it.each(['multi-tenant-replay', 'skip-seed-data'] as const)( + 'a source suppressed as %s stays pending and names its reason', + (reason) => { + const ctx = makeCtx(); + + declareSeedSource(ctx).suppress(reason); + + expect(readSeedSettlement(ctx)).toEqual({ + pending: 1, + inFlight: 0, + suppressed: [reason], + }); + }, + ); + + /** + * #3453's trap, in this tally's terms: `registerService` throws on a + * duplicate name, so a second config app must extend the tracker the first + * one registered instead of clobbering it or losing itself in a `catch`. + */ + it('a second source extends the first source tally rather than replacing it', () => { + const ctx = makeCtx(); + const a = declareSeedSource(ctx); + const b = declareSeedSource(ctx); + + expect(readSeedSettlement(ctx)?.pending).toBe(2); + + a.settle(); + expect(readSeedSettlement(ctx)?.pending).toBe(1); + + b.settle(); + expect(readSeedSettlement(ctx)?.pending).toBe(0); + }); + + it('mixes in-flight and suppressed sources in one tally', () => { + const ctx = makeCtx(); + declareSeedSource(ctx); // still writing + declareSeedSource(ctx).suppress('multi-tenant-replay'); + + expect(readSeedSettlement(ctx)).toEqual({ + pending: 2, + inFlight: 1, + suppressed: ['multi-tenant-replay'], + }); + }); + + /** + * Both handle methods are terminal. Without this a double-settle (the + * in-budget path racing a background continuation) would drive the tally + * negative and read as "everything landed" while a source was still + * writing — the exact false-clear this module exists to prevent. + */ + it('settle and suppress are idempotent and terminal', () => { + const ctx = makeCtx(); + const a = declareSeedSource(ctx); + const b = declareSeedSource(ctx); + + a.settle(); + a.settle(); + a.suppress('skip-seed-data'); // after settling: ignored + + expect(readSeedSettlement(ctx)).toEqual({ pending: 1, inFlight: 1, suppressed: [] }); + + b.suppress('skip-seed-data'); + b.settle(); // after suppressing: ignored + + expect(readSeedSettlement(ctx)).toEqual({ + pending: 1, + inFlight: 0, + suppressed: ['skip-seed-data'], + }); + }); + + it('hands back a copy, so a consumer cannot edit the tally it read', () => { + const ctx = makeCtx(); + declareSeedSource(ctx).suppress('multi-tenant-replay'); + + const snap = readSeedSettlement(ctx)!; + (snap.suppressed as string[]).push('skip-seed-data'); + + expect(readSeedSettlement(ctx)?.suppressed).toEqual(['multi-tenant-replay']); + }); + + /** + * Best-effort like the rest of the seed wiring: a context that cannot carry + * services gets an inert handle and the consumer simply finds no service, + * which reads as "no seed pipeline" — the pre-#4795 behaviour, never a + * boot failure. + */ + it('never throws on a context that cannot register services', () => { + expect(() => declareSeedSource({}).settle()).not.toThrow(); + expect(() => declareSeedSource(undefined).suppress('skip-seed-data')).not.toThrow(); + expect(readSeedSettlement({})).toBeUndefined(); + }); + + it('reads through a bare kernel handle as well as a plugin context', () => { + const kernel = makeCtx(); + const ctx = { kernel }; + + declareSeedSource(ctx); + + expect(readSeedSettlement(ctx)?.pending).toBe(1); + expect(readSeedSettlement(kernel)?.pending).toBe(1); + }); +}); diff --git a/packages/runtime/src/seed-settlement.ts b/packages/runtime/src/seed-settlement.ts new file mode 100644 index 0000000000..e79f6a22b2 --- /dev/null +++ b/packages/runtime/src/seed-settlement.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Producer side of the `seed-settlement` contract (#4795). + * + * The contract, and why the consumer may not just look at `seed-datasets` + * instead, is documented once in + * `@objectstack/spec/contracts/seed-settlement.ts`. This module is the + * bookkeeping behind it: every seeding host declares its source before it + * decides what to do with it, and settles that source at the exact moment its + * boot-time write is done — the same moment it emits `app:seeded`. + * + * Registration follows the register-once-then-mutate discipline + * {@link ./seed-datasets.ts} spells out: `registerService` THROWS on a + * duplicate name, and a second config app (or a marketplace install) must + * extend the tally rather than clobber it or lose itself in a `catch`. So the + * tracker object is registered by the first source and read back by every + * later one. + * + * **A source that is never settled is not a leak — it is the answer.** A + * multi-tenant or `skipSeedData` boot suppresses its source on purpose and the + * tally stays pending for the life of the process, which is what tells the + * ADR-0104 attestation to stand down rather than certify rows nobody wrote. + * The same holds accidentally: a host that throws between declaring and + * settling leaves the deployment warn-first, which is the recoverable + * direction (`os migrate … --apply` closes it on real evidence) and strictly + * better than certifying over a seed whose fate is unknown. + */ + +import type { + ISeedSettlementService, + SeedSettlementSnapshot, + SeedSuppressionReason, +} from '@objectstack/spec/contracts'; +import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; + +/** The subset of a kernel / plugin context this module pokes at. */ +interface SeedSettlementHost { + kernel?: { getService?: (n: string) => unknown; registerService?: (n: string, v: unknown) => unknown }; + getService?: (n: string) => unknown; + registerService?: (n: string, v: unknown) => unknown; +} + +/** + * Handle a seeding host holds over its own declared source. Both methods are + * idempotent and terminal: the first call decides the source's fate and every + * later one is a no-op, so a double-settle (the in-budget path racing a + * background continuation) cannot drive the tally negative. + */ +export interface SeedSourceHandle { + /** This source's boot-time write is done — the rows it will write are in. */ + settle(): void; + /** This source will not run at boot; nothing it describes gets written. */ + suppress(reason: SeedSuppressionReason): void; +} + +/** The registered service object: the read-only contract plus its bookkeeping. */ +interface SeedSettlementTracker extends ISeedSettlementService { + __declare(): SeedSourceHandle; +} + +/** A no-op handle for hosts whose context cannot carry the service at all. */ +const INERT_HANDLE: SeedSourceHandle = { settle: () => {}, suppress: () => {} }; + +function createTracker(): SeedSettlementTracker { + let inFlight = 0; + const suppressed: SeedSuppressionReason[] = []; + + return { + snapshot(): SeedSettlementSnapshot { + return { + pending: inFlight + suppressed.length, + inFlight, + // Copy: a consumer must not be able to edit the tally it reads. + suppressed: [...suppressed], + }; + }, + __declare(): SeedSourceHandle { + inFlight += 1; + let closed = false; + return { + settle() { + if (closed) return; + closed = true; + inFlight -= 1; + }, + suppress(reason: SeedSuppressionReason) { + if (closed) return; + closed = true; + inFlight -= 1; + suppressed.push(reason); + }, + }; + }, + }; +} + +function readTracker(ctx: unknown): SeedSettlementTracker | undefined { + const c = ctx as SeedSettlementHost | null | undefined; + const looksLikeTracker = (v: unknown): v is SeedSettlementTracker => + !!v && typeof (v as SeedSettlementTracker).__declare === 'function'; + if (typeof c?.getService === 'function') { + try { const v = c.getService(SEED_SETTLEMENT_SERVICE); if (looksLikeTracker(v)) return v; } catch { /* unregistered */ } + } + if (typeof c?.kernel?.getService === 'function') { + try { const v = c.kernel.getService(SEED_SETTLEMENT_SERVICE); if (looksLikeTracker(v)) return v; } catch { /* unregistered */ } + } + return undefined; +} + +/** + * Declare one seed source on this kernel and get the handle that closes it. + * + * Call this BEFORE deciding whether the source runs inline, replays per org, or + * is suppressed — the whole point is that the source is counted from the moment + * it is known to exist, so a consumer reading at `kernel:ready` can never catch + * the pipeline in a gap where a real source is invisible. + * + * Best-effort like the rest of the seed wiring: a context that cannot register + * services gets an inert handle and the consumer simply finds no service, which + * reads as "no seed pipeline" — the pre-#4795 behaviour, never a boot failure. + */ +export function declareSeedSource(ctx: unknown): SeedSourceHandle { + const c = ctx as SeedSettlementHost | null | undefined; + const existing = readTracker(ctx); + if (existing) return existing.__declare(); + + const tracker = createTracker(); + try { + if (typeof c?.kernel?.registerService === 'function') c.kernel.registerService(SEED_SETTLEMENT_SERVICE, tracker); + else if (typeof c?.registerService === 'function') c.registerService(SEED_SETTLEMENT_SERVICE, tracker); + else return INERT_HANDLE; + } catch { + // Lost a check→register race with a concurrent source: use the tracker + // that won rather than a private one nobody can read. + const winner = readTracker(ctx); + return winner ? winner.__declare() : INERT_HANDLE; + } + return tracker.__declare(); +} + +/** + * Read the live tally, for hosts that want it without declaring a source. + * Returns `undefined` when no seed pipeline has registered on this kernel. + */ +export function readSeedSettlement(ctx: unknown): SeedSettlementSnapshot | undefined { + return readTracker(ctx)?.snapshot(); +} diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index e272d6037e..da9e5c5ee2 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -133,6 +133,7 @@ "ISearchService (interface)", "ISecurityService (interface)", "ISeedLoaderService (interface)", + "ISeedSettlementService (interface)", "IServiceRegistry (interface)", "IShareLinkService (interface)", "ISharingRuleService (interface)", @@ -226,6 +227,7 @@ "RlsMembershipContext (interface)", "RollbackInput (interface)", "RouteHandler (type)", + "SEED_SETTLEMENT_SERVICE (const)", "SHARE_LINK_SERVICE (const)", "SaveReportInput (interface)", "SavedReport (interface)", @@ -239,6 +241,8 @@ "SearchOptions (interface)", "SearchResult (interface)", "SecurityContext (type)", + "SeedSettlementSnapshot (interface)", + "SeedSuppressionReason (type)", "SendEmailInput (interface)", "SendEmailResult (interface)", "SendSmsInput (interface)", diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index d428edbef9..64f42aff82 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -66,5 +66,6 @@ export * from './external-datasource-service.js'; export * from './deploy-pipeline-service.js'; export * from './app-lifecycle-service.js'; export * from './seed-loader-service.js'; +export * from './seed-settlement.js'; export * from './crypto-provider.js'; export * from './cluster-service.js'; diff --git a/packages/spec/src/contracts/seed-settlement.ts b/packages/spec/src/contracts/seed-settlement.ts new file mode 100644 index 0000000000..491c6047d7 --- /dev/null +++ b/packages/spec/src/contracts/seed-settlement.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Seed-settlement signal — "has this boot's own seed data finished landing?" + * + * ## Why this is a contract and not a service-name sniff (#4795) + * + * ADR-0104's fresh-datastore attestation (`platform-objects`) certifies "this + * store was created empty, so no legacy value can exist here". The inference is + * about CONTENT, and it expires the moment the boot writes. #4769 moved the + * attestation off `kernel:ready` onto `app:seeded` for exactly that reason, + * keeping `kernel:ready` as the backstop for kernels that never seed. + * + * That backstop still fires too early in one case. `AppPlugin`'s inline seed + * races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s); over budget it + * logs a warning and finishes **in the background**, so `app:seeded` lands after + * `kernel:ready`. The backstop then certifies a store whose seed is still + * writing, flips the gates to strict mid-run, and the tail of the SAME seed run + * meets a contract its head never saw — one boot, two contracts. Measured on a + * showcase cold boot with a 1ms budget: attestation at `+0.470s`, seed settled + * at `+3.617s` — a 3.147s window. + * + * The consumer therefore has to ask "is a seed still in flight?" — and the + * obvious way to ask, poking at the runtime's internal `seed-datasets` service + * and guessing from an array's length, is precisely the coupling this contract + * exists to avoid: an array being registered says a seed source EXISTS, never + * whether it has SETTLED, and the two differ for the whole window that is the + * bug. So the producer publishes the settlement fact itself, under a name both + * sides import from the spec. + * + * ## Reading it correctly + * + * The question is asked at `kernel:ready` or later — never during `init()`. + * Every seed source is declared in the producer's `start()` (kernel Phase 2), + * which completes before `kernel:ready` (Phase 3), so an ABSENT service at that + * point is a fact ("no seed pipeline on this kernel") rather than a not-yet. + * Asking earlier would record a verdict the same boot can still contradict — + * the shape `pnpm check:startup-registry-verdict` guards. + * + * ## Suppressed sources never settle, and that is the answer, not a gap + * + * Two deployment shapes register seed datasets and then deliberately do not run + * them at boot, so `app:seeded` never fires: + * + * - **multi-tenant** — seeds are replayed per organization on `sys_organization` + * insert, so at boot the rows they describe do not exist yet; + * - **`skipSeedData`** — an `os migrate` planning boot (#3917) that must not + * write to the target database at all. + * + * Both report `pending > 0` for the life of the boot, which makes the ADR-0104 + * posture for them fall out of this one predicate: **do not self-certify at + * boot; wait for `os migrate … --apply` to record the flag on a real scan.** + * That is the correct answer rather than a missing one — certifying at startup + * that a per-org replay which has not happened yet holds no violating value is + * #4769's error with a longer fuse. + */ + +/** + * Why a registered seed source will not settle during this boot. + * + * Both values mean "the rows this source describes are not being written by + * this boot", so nothing observed at startup can prove or disprove a claim + * about them. + */ +export type SeedSuppressionReason = + /** Multi-tenant: seeds replay per org on `sys_organization` insert. */ + | 'multi-tenant-replay' + /** `skipSeedData`: a read-only planning boot that writes nothing (#3917). */ + | 'skip-seed-data'; + +/** A point-in-time reading of the kernel's seed pipeline. */ +export interface SeedSettlementSnapshot { + /** + * Seed sources whose boot-time write has not settled — the sum of + * {@link inFlight} and {@link suppressed}. `0` means every registered source + * has finished writing (or none was ever registered), and a consumer may act + * on what the store now holds. + */ + readonly pending: number; + + /** + * Sources still writing. Each will settle and emit `app:seeded`, so a + * consumer that defers on this will be called again. + */ + readonly inFlight: number; + + /** + * One entry per source this boot suppressed; these never settle and no + * `app:seeded` follows. Carried as reasons rather than a count so a consumer + * can say *why* it is standing down. + */ + readonly suppressed: readonly SeedSuppressionReason[]; +} + +/** + * Read-only settlement probe, registered by the runtime under + * {@link SEED_SETTLEMENT_SERVICE}. + * + * Deliberately read-only: declaring and settling sources is the seeding + * host's own business, and a consumer that could mutate the tally could + * certify itself. The surface is one method because one question is all the + * ADR-0104 attestation needs. + */ +export interface ISeedSettlementService { + /** Read the live tally. Never cached by the caller — re-read per use. */ + snapshot(): SeedSettlementSnapshot; +} + +/** Service-registry slot for {@link ISeedSettlementService}. */ +export const SEED_SETTLEMENT_SERVICE = 'seed-settlement' as const;