Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/defer-adr0104-attestation-while-seed-in-flight.md
Original file line number Diff line number Diff line change
@@ -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 开关仍然有效。
18 changes: 14 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand Down
166 changes: 166 additions & 0 deletions packages/platform-objects/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof seedSettlementFake>[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');
});
});
});
115 changes: 110 additions & 5 deletions packages/platform-objects/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading