From 8f92c290c8f4000468ebc1dab27146093b2a0aac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:37:44 +0000 Subject: [PATCH 1/2] fix(platform-objects): defer ADR-0104 attestation while this boot's seed is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4769 moved the fresh-datastore attestation off `kernel:ready` onto `app:seeded` — the settle point for this boot's own data — keeping `kernel:ready` as the backstop for kernels that never seed. The remaining window is that the two hooks can arrive in EITHER order: when AppPlugin's inline seed overruns `OS_INLINE_SEED_BUDGET_MS` it continues in the background, so `kernel:ready` lands first and the backstop certified the store mid-seed, flipping the value-shape gates to strict while the same seed run was still writing. One boot, two contracts. Measured on a showcase cold boot at `OS_INLINE_SEED_BUDGET_MS=1`: attestation at +0.470s, seed settled at +3.617s — a 3.147s window. Both hooks now ask whether this boot's seed has settled before certifying anything, `app:seeded` included: a multi-app bundle fires it once per app, so the first one is not the boot's settle point. The signal is a published `seed-settlement` contract rather than a sniff of 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 whole window. The runtime declares each seed source before choosing what to do with it and settles it when the write is actually done. Posture for multi-tenant and `skipSeedData` (ruled 2026-08-06, #4795): both register seed datasets and deliberately do not write them at boot, so `app:seeded` never fires and the tally stays pending. They do not self-certify at boot and wait for `os migrate … --apply` to record the flag on a real scan — which falls out of the same predicate rather than needing a branch of its own. objectql carries a comment-only change: the background-seed ordering is no longer a scenario #4769's revocation mechanism has to catch, since it is now closed at the source. It stays live for the `os dev` hot-reload seeder, a runtime marketplace install, and the lax env-var escapes. Fixes #4795 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- ...dr0104-attestation-while-seed-in-flight.md | 18 ++ packages/objectql/src/engine.ts | 18 +- packages/platform-objects/src/plugin.test.ts | 166 ++++++++++++++++++ packages/platform-objects/src/plugin.ts | 115 +++++++++++- packages/runtime/src/app-plugin.seed.test.ts | 111 ++++++++++++ packages/runtime/src/app-plugin.ts | 28 +++ packages/runtime/src/index.ts | 2 + packages/runtime/src/seed-settlement.test.ts | 153 ++++++++++++++++ packages/runtime/src/seed-settlement.ts | 148 ++++++++++++++++ packages/spec/api-surface/contracts.json | 4 + packages/spec/src/contracts/index.ts | 1 + .../spec/src/contracts/seed-settlement.ts | 110 ++++++++++++ 12 files changed, 865 insertions(+), 9 deletions(-) create mode 100644 .changeset/defer-adr0104-attestation-while-seed-in-flight.md create mode 100644 packages/runtime/src/seed-settlement.test.ts create mode 100644 packages/runtime/src/seed-settlement.ts create mode 100644 packages/spec/src/contracts/seed-settlement.ts 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 0d234a2369..4a07f9625b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3931,10 +3931,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 aa8a981dfc..9343f9a526 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -132,6 +132,7 @@ "ISearchService (interface)", "ISecurityService (interface)", "ISeedLoaderService (interface)", + "ISeedSettlementService (interface)", "IServiceRegistry (interface)", "IShareLinkService (interface)", "ISharingRuleService (interface)", @@ -225,6 +226,7 @@ "RlsMembershipContext (interface)", "RollbackInput (interface)", "RouteHandler (type)", + "SEED_SETTLEMENT_SERVICE (const)", "SHARE_LINK_SERVICE (const)", "SaveReportInput (interface)", "SavedReport (interface)", @@ -238,6 +240,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; From d446c46682750ccf530b77fdb4c26f200a60ba0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:45:49 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge driver defers generated artifacts rather than text-merging them (AGENTS.md §11); this is the regeneration from the merged tree. Restores main's `DriverQuery` entry alongside this branch's seed-settlement exports. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- packages/spec/api-surface/contracts.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 9343f9a526..da9e5c5ee2 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -66,6 +66,7 @@ "DelegableAdminScope (interface)", "DelegableScope (interface)", "DeployExecutionResult (interface)", + "DriverQuery (type)", "EMBEDDER_SERVICE (const)", "EmailAddress (type)", "EmailAttachment (interface)",