Skip to content

Commit eda599e

Browse files
baozhoutaoclaude
andauthored
fix(platform-objects): 超预算后台 seed 期间不再空库自证 —— 一次启动不再跑两套契约 (#4795) (#6147)
* fix(platform-objects): defer ADR-0104 attestation while this boot's seed is in flight #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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We * chore(spec): regenerate api-surface after merging main 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent acf34e3 commit eda599e

12 files changed

Lines changed: 865 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
fix(platform-objects): 超预算后台 seed 期间不再空库自证 —— 一次启动不再跑两套契约
9+
10+
#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。
11+
12+
现在两个钩子都先问一句「本次启动自己的 seed 落定了吗」,任一处报告仍有未结算的 seed 源就不签发。`app:seeded` 同样受这道检查约束——多 config app 的 bundle 会每个 app 触发一次,第一次并不是本次启动的结算点。
13+
14+
新增 `seed-settlement` 契约(`@objectstack/spec/contracts`)承载这个信号,而不是让 platform-objects 去嗅 runtime 内部的 `seed-datasets` 服务:那个数组的存在只能说明「seed 源存在」,永远说明不了「已经落定」,而这两件事之间的差正是本 bug 的整个窗口。runtime 在选择分支之前先声明 seed 源,并在写入真正结束的同一刻结算它。
15+
16+
**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` 关闭。
17+
18+
`@objectstack/objectql` 侧只更新了 #4769 撤销机制的注释:「后台 seed 收尾晚于签发」不再是它要兜的场景(已在源头关闭),它对 `os dev` 热重载 seeder、运行期 marketplace 安装以及 lax 开关仍然有效。

packages/objectql/src/engine.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3952,10 +3952,20 @@ export class ObjectQL implements IObjectQLEngine {
39523952
* order — the certificate is already in the ledger and the contradicting
39533953
* value lands afterwards, which is reachable whenever the deployment is
39543954
* still lenient at that moment (`OS_ALLOW_LAX_MEDIA_VALUES` /
3955-
* `OS_ALLOW_LAX_VALUE_SHAPES`, or a seed that finishes in the background
3956-
* after its budget). Without this the ledger would keep asserting a fact the
3957-
* store contradicts, and the NEXT boot would enforce it against exactly the
3958-
* data this one wrote.
3955+
* `OS_ALLOW_LAX_VALUE_SHAPES`) or whenever a writer runs after the
3956+
* attestation point at all — the `os dev` hot-reload seeder and a runtime
3957+
* marketplace install both seed on a store this boot created. Without this
3958+
* the ledger would keep asserting a fact the store contradicts, and the NEXT
3959+
* boot would enforce it against exactly the data this one wrote.
3960+
*
3961+
* The boot's own inline seed used to head that list, via the background
3962+
* continuation of a run that overran `OS_INLINE_SEED_BUDGET_MS` — the
3963+
* attestation's `kernel:ready` backstop fired mid-seed and the tail landed
3964+
* against the certificate it had just issued. #4795 closed that ordering at
3965+
* the source: the attestation now defers while the `seed-settlement` contract
3966+
* reports a source outstanding, so the inline seed can no longer contradict
3967+
* a certificate this boot issued. This stays the safety net rather than the
3968+
* first line of defence for it.
39593969
*
39603970
* Deliberately narrow:
39613971
*

packages/platform-objects/src/plugin.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,170 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
263263
expect(engine.rows.map((r: any) => r.id)).toEqual(['adr-0104-value-shapes']);
264264
});
265265
});
266+
267+
/**
268+
* #4795 — `app:seeded` and `kernel:ready` can arrive in EITHER order.
269+
*
270+
* When the inline seed overruns `OS_INLINE_SEED_BUDGET_MS` the runtime hands
271+
* it to the background, so `kernel:ready` lands first and the #4769 backstop
272+
* fired mid-seed: it certified the store, flipped the gates to strict, and
273+
* the tail of the same seed run met a contract its head never saw — one
274+
* boot, two contracts. Measured on a showcase cold boot at
275+
* `OS_INLINE_SEED_BUDGET_MS=1`: attestation +0.470s, seed settled +3.617s.
276+
*
277+
* The fix asks the published `seed-settlement` contract instead of guessing
278+
* from the runtime's internal `seed-datasets` array — see the contract's own
279+
* TSDoc for why an array's presence cannot answer this.
280+
*/
281+
describe('defers while this boot own seed is still landing (#4795)', () => {
282+
/** Fake `seed-settlement` service over mutable state a test can advance. */
283+
function seedSettlementFake(state: {
284+
inFlight?: number;
285+
suppressed?: Array<'multi-tenant-replay' | 'skip-seed-data'>;
286+
}) {
287+
return {
288+
snapshot: () => ({
289+
pending: (state.inFlight ?? 0) + (state.suppressed?.length ?? 0),
290+
inFlight: state.inFlight ?? 0,
291+
suppressed: [...(state.suppressed ?? [])],
292+
}),
293+
};
294+
}
295+
296+
async function bootWithSeed(engine: unknown, state: Parameters<typeof seedSettlementFake>[0]) {
297+
const plugin = new PlatformObjectsPlugin();
298+
const ctx = makeCtx();
299+
ctx.registerService('objectql', engine);
300+
ctx.registerService('seed-settlement', seedSettlementFake(state));
301+
await plugin.init(ctx);
302+
await plugin.start(ctx);
303+
return ctx;
304+
}
305+
306+
/**
307+
* The nail for this issue: at `kernel:ready` the background seed is still
308+
* writing, so nothing may be certified yet — and once it settles, the
309+
* certificate lands normally. One contract for the whole seed run.
310+
*/
311+
it('kernel:ready writes no attestation while a seed source is still in flight', async () => {
312+
const engine = engineWith(true);
313+
const state = { inFlight: 1 };
314+
315+
const ctx = await bootWithSeed(engine, state);
316+
await ctx._flushReady();
317+
318+
expect(engine.rows).toHaveLength(0);
319+
320+
// The background seed finishes and the runtime emits `app:seeded`.
321+
state.inFlight = 0;
322+
await ctx._flush('app:seeded');
323+
324+
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
325+
'adr-0104-file-references',
326+
'adr-0104-value-shapes',
327+
]);
328+
});
329+
330+
/**
331+
* `app:seeded` fires once per config app, so the FIRST one is not the
332+
* settle point for the boot. Guarding only `kernel:ready` would move the
333+
* same split-contract window onto multi-app bundles.
334+
*/
335+
it('an app:seeded from one config app does not certify while another is still writing', async () => {
336+
const engine = engineWith(true);
337+
const state = { inFlight: 2 };
338+
339+
const ctx = await bootWithSeed(engine, state);
340+
341+
state.inFlight = 1; // app A settled; app B still writing
342+
await ctx._flush('app:seeded');
343+
expect(engine.rows).toHaveLength(0);
344+
345+
state.inFlight = 0; // app B settled
346+
await ctx._flush('app:seeded');
347+
expect(engine.rows).toHaveLength(2);
348+
});
349+
350+
/**
351+
* The #4795 ruling (2026-08-06), pinned: a deployment whose seed never runs
352+
* at boot does not self-certify — it waits for `os migrate`. Falls out of
353+
* the same predicate rather than needing a branch of its own.
354+
*/
355+
it.each([
356+
['multi-tenant', 'multi-tenant-replay' as const],
357+
['skipSeedData', 'skip-seed-data' as const],
358+
])('%s: attests nothing at boot, without erroring', async (_label, reason) => {
359+
const engine = engineWith(true);
360+
361+
const ctx = await bootWithSeed(engine, { suppressed: [reason] });
362+
await expect(ctx._flushReady()).resolves.toBeUndefined();
363+
364+
expect(engine.rows).toHaveLength(0);
365+
});
366+
367+
it('says why it stood down, and names the command that closes the gate', async () => {
368+
const engine = engineWith(true);
369+
370+
const ctx = await bootWithSeed(engine, { suppressed: ['multi-tenant-replay'] });
371+
await ctx._flushReady();
372+
373+
const said = ctx._logs.info.join('\n');
374+
expect(said).toContain('multi-tenant-replay');
375+
expect(said).toContain('os migrate value-shapes --apply');
376+
// A posture that is correct by design must not spend the level that
377+
// means "something you trusted did not persist" (AGENTS.md).
378+
expect(ctx._logs.warn.join('\n')).not.toContain('not attesting');
379+
});
380+
381+
it('an in-flight deferral says it will be picked up on app:seeded', async () => {
382+
const engine = engineWith(true);
383+
384+
const ctx = await bootWithSeed(engine, { inFlight: 1 });
385+
await ctx._flushReady();
386+
387+
expect(ctx._logs.info.join('\n')).toContain('app:seeded');
388+
});
389+
390+
/**
391+
* Regression guard for the two paths this change must leave untouched:
392+
* a seed that fits inside its budget (settled before `kernel:ready`), and
393+
* a kernel with no seed pipeline at all — the backstop #4769 kept for
394+
* exactly that case, which is the same moment as before for it.
395+
*/
396+
it('a settled seed attests at kernel:ready exactly as before', async () => {
397+
const engine = engineWith(true);
398+
399+
const ctx = await bootWithSeed(engine, { inFlight: 0 });
400+
await ctx._flushReady();
401+
402+
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
403+
'adr-0104-file-references',
404+
'adr-0104-value-shapes',
405+
]);
406+
});
407+
408+
it('a kernel with no seed pipeline still attests on the kernel:ready backstop', async () => {
409+
const engine = engineWith(true);
410+
const plugin = new PlatformObjectsPlugin();
411+
const ctx = makeCtx();
412+
ctx.registerService('objectql', engine); // no `seed-settlement` service
413+
await plugin.init(ctx);
414+
await plugin.start(ctx);
415+
416+
await ctx._flushReady();
417+
418+
expect(engine.rows).toHaveLength(2);
419+
});
420+
421+
/**
422+
* A store that was FOUND was never going to be attested, so announcing a
423+
* deferral over it would explain a decision nobody was making.
424+
*/
425+
it('says nothing about deferral on a store that already existed', async () => {
426+
const ctx = await bootWithSeed(engineWith(false), { inFlight: 1 });
427+
await ctx._flushReady();
428+
429+
expect(ctx._logs.info.join('\n')).not.toContain('attestation deferred');
430+
});
431+
});
266432
});

packages/platform-objects/src/plugin.ts

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { SysMigration } from './system/sys-migration.object.js';
66
import { SysMigrationJournal } from './system/sys-migration-journal.object.js';
77
import { SysSecret } from './system/sys-secret.object.js';
88
import { attestFreshDatastore } from './system/migration-flag.js';
9-
import type { II18nService, IObjectQLEngine } from '@objectstack/spec/contracts';
9+
import type {
10+
II18nService,
11+
IObjectQLEngine,
12+
ISeedSettlementService,
13+
SeedSettlementSnapshot,
14+
} from '@objectstack/spec/contracts';
15+
import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts';
1016

1117

1218
/**
@@ -48,7 +54,10 @@ import type { II18nService, IObjectQLEngine } from '@objectstack/spec/contracts'
4854
* seed), whichever services are composed. Not before: emptiness settles
4955
* a claim about CONTENT, and a boot that certifies itself and then seeds
5056
* rows contradicting the certificate leaves every later boot enforcing
51-
* it against data this one wrote (#4769).
57+
* it against data this one wrote (#4769). The `kernel:ready` fallback
58+
* additionally asks the `seed-settlement` contract whether a seed is
59+
* still in flight, so an over-budget background seed is waited out
60+
* rather than certified over (#4795).
5261
* - **Translation bundles** — `SetupAppTranslations` (the static Setup
5362
* App + sys_* dashboards) and `MetadataFormsTranslations`
5463
* (`metadataForms.*` for object/field/agent/flow/view configuration
@@ -116,7 +125,26 @@ export class PlatformObjectsPlugin {
116125
// (above; #4243 — moved here with the registration from
117126
// service-storage). A store that was found rather than created attests
118127
// nothing and keeps producing evidence by scan.
119-
const attest = async () => {
128+
// #4795 — "has this boot's own seed finished landing?", asked through the
129+
// published `seed-settlement` contract rather than by sniffing the
130+
// runtime's internal `seed-datasets` service. That array's presence says a
131+
// seed source EXISTS; it can never say whether it has SETTLED, and the gap
132+
// between those two facts IS the bug. An absent service means no seed
133+
// pipeline registered on this kernel — a fact by `kernel:ready`, since
134+
// every source is declared in Phase 2 `start()`.
135+
const readSeedSettlement = (): SeedSettlementSnapshot | undefined => {
136+
try {
137+
const svc = ctx.getService?.(SEED_SETTLEMENT_SERVICE) as
138+
| ISeedSettlementService
139+
| undefined;
140+
if (!svc || typeof svc.snapshot !== 'function') return undefined;
141+
return svc.snapshot();
142+
} catch {
143+
return undefined;
144+
}
145+
};
146+
147+
const attest = async (phase: 'app:seeded' | 'kernel:ready') => {
120148
let engine: IObjectQLEngine | undefined;
121149
try {
122150
engine = ctx.getService?.('objectql');
@@ -126,6 +154,15 @@ export class PlatformObjectsPlugin {
126154
if (!engine || typeof engine.wasDatastoreCreatedFromEmpty !== 'function') return;
127155
try {
128156
if (engine.wasDatastoreCreatedFromEmpty()) {
157+
// Asked AFTER the created-from-empty check on purpose: a store that
158+
// was found rather than created attests nothing either way, and
159+
// announcing a deferral there would be noise about a decision that
160+
// was never going to be made.
161+
const seed = readSeedSettlement();
162+
if (seed && seed.pending > 0) {
163+
if (phase === 'kernel:ready') reportDeferral(ctx, seed);
164+
return;
165+
}
129166
await attestFreshDatastore(engine, { logger: ctx.logger });
130167
// The engine memoizes the flag read on first use; this write
131168
// may already have raced it on a fast boot.
@@ -151,8 +188,20 @@ export class PlatformObjectsPlugin {
151188
// (it is the same moment as before for those). Both land in the same
152189
// idempotent call: the first one to find an id unattested and
153190
// uncontradicted writes it, the other finds the row and skips.
154-
ctx?.hook?.('app:seeded', attest);
155-
ctx?.hook?.('kernel:ready', attest);
191+
//
192+
// #4795 — subscribing to both is necessary but not sufficient, because the
193+
// two can arrive in EITHER order. When the inline seed overruns its budget
194+
// the runtime hands it to the background and `kernel:ready` arrives first,
195+
// so the backstop fired mid-seed: it certified the store, flipped the gates
196+
// to strict, and the tail of the same seed run met a contract its head had
197+
// never seen — one boot, two contracts. Measured on a showcase cold boot at
198+
// `OS_INLINE_SEED_BUDGET_MS=1`: attestation +0.470s, seed settled +3.617s.
199+
// Neither hook may certify while the pipeline reports work outstanding, so
200+
// the settlement check lives inside `attest` and guards both — `app:seeded`
201+
// included, since a bundle with several config apps fires it once per app
202+
// and the first one is not the last.
203+
ctx?.hook?.('app:seeded', () => attest('app:seeded'));
204+
ctx?.hook?.('kernel:ready', () => attest('kernel:ready'));
156205

157206
ctx?.hook?.('kernel:ready', async () => {
158207
let i18n: II18nService | undefined;
@@ -194,6 +243,62 @@ export class PlatformObjectsPlugin {
194243
}
195244
}
196245

246+
/**
247+
* Say, once, why `kernel:ready` did not attest — and what closes the gate.
248+
*
249+
* ## The ADR-0104 posture for deployments that never settle a boot seed (#4795)
250+
*
251+
* Two shapes register seed datasets and deliberately do not run them at boot,
252+
* so `app:seeded` never fires and the tally stays pending for the life of the
253+
* process: **multi-tenant** (seeds replay per organization on
254+
* `sys_organization` insert) and **`skipSeedData`** (an `os migrate` planning
255+
* boot that must not write to the target database at all, #3917).
256+
*
257+
* Their posture is **do not self-certify at boot; wait for `os migrate`** —
258+
* ruled 2026-08-06 and recorded on #4795. It is not a gap this check leaves
259+
* behind, it is the answer:
260+
*
261+
* - the fresh-datastore attestation infers "created empty, therefore no
262+
* legacy value can exist". On a multi-tenant deployment the rows that
263+
* inference is about have not been written yet — they land org by org,
264+
* later. Certifying at startup that a replay which has not happened holds
265+
* no violating value is exactly #4769's error with a longer fuse;
266+
* - a `skipSeedData` boot writes nothing, so it observes nothing, so it has
267+
* no evidence to certify from;
268+
* - standing down is the *recoverable* direction. The deployment stays
269+
* warn-first — true, and closable at any time by `os migrate value-shapes
270+
* --apply` / `os migrate files-to-references --apply`, which record the flag
271+
* on a real scan of what the store actually holds. The opposite error is not
272+
* recoverable in the same way: a certificate issued over rows nobody looked
273+
* at is enforced by every later boot against data it never examined.
274+
*
275+
* Logged at `info`, not `warn`. This is a functional posture, not a durability
276+
* degradation: nothing claims to have persisted and failed, and the gate that
277+
* stays open is the lenient one. A `warn` on every boot of every multi-tenant
278+
* deployment for behaviour that is correct by design is precisely what trains
279+
* operators to skim the level that matters (AGENTS.md, degradation log levels).
280+
*/
281+
function reportDeferral(ctx: any, seed: SeedSettlementSnapshot): void {
282+
if (seed.suppressed.length > 0) {
283+
const reasons = [...new Set(seed.suppressed)].join(', ');
284+
ctx?.logger?.info?.(
285+
`[platform-objects] not attesting this fresh datastore at boot: seed data is registered but ` +
286+
`this boot does not write it (${reasons}). Multi-tenant deployments replay seeds per org on ` +
287+
`sys_organization insert, and a skipSeedData boot writes nothing at all — so nothing observed ` +
288+
`now could prove or disprove the claim. The deployment stays warn-first until ` +
289+
`\`os migrate value-shapes --apply\` / \`os migrate files-to-references --apply\` records the ` +
290+
`flag on a real scan (ADR-0104, #4795).`,
291+
);
292+
return;
293+
}
294+
ctx?.logger?.info?.(
295+
`[platform-objects] fresh-datastore attestation deferred at kernel:ready: ${seed.inFlight} seed ` +
296+
`source(s) still writing (an inline seed overran OS_INLINE_SEED_BUDGET_MS and continues in the ` +
297+
`background). Attesting now would flip this boot to strict half-way through its own seed run. ` +
298+
`It runs on \`app:seeded\` once the seed settles (ADR-0104, #4795).`,
299+
);
300+
}
301+
197302
/** Convenience factory mirroring the rest of the plugin ecosystem. */
198303
export function createPlatformObjectsPlugin(): PlatformObjectsPlugin {
199304
return new PlatformObjectsPlugin();

0 commit comments

Comments
 (0)