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
51 changes: 51 additions & 0 deletions .changeset/event-schema-dual-source-c6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"@objectstack/spec": major
---

BREAKING(spec): `@objectstack/spec/automation` 不再导出 `EventSchema` —— 该名字在本包内曾指向**两个键集完全不相交的声明**,automation 侧是孤儿,已删除;`EventSchema` 现在全包唯一地指 `@objectstack/spec/kernel` 的事件总线信封 (#4658, #4535 C6)

`EventSchema` 过去被两个入口导出,但**不是同一个声明**,拿到哪个只取决于 import 路径 —— #4411 陷阱。且两侧不是宽窄之差,是**两个概念**,键集零重叠:

| 入口 | 声明位置 | 形状 | 概念 |
|:--|:--|:--|:--|
| `@objectstack/spec/automation`(**本次删除**) | `automation/state-machine.zod.ts` | `{ type, schema? }` | XState 式**信号声明**(「这台状态机接受哪些事件」) |
| `@objectstack/spec/kernel`(**不变,唯一真源**) | `kernel/events/core.zod.ts` | `{ id?, name, payload, metadata }` | 事件总线**信封**(一条已发出的事件实例) |

automation 侧是**孤儿声明**:`StateMachineSchema` 从不引用它(状态机的事件类型是 `on:` 的**记录键**,纯字符串),`packages/spec` 内外零消费者(objectstack / cloud / objectui 三仓 import 语句级实测均为零)。收敛会把「状态机信号声明」写成「事件总线信封」—— 在合同里写假话 —— 故按维护者裁决(#4658,路线 A)删除孤儿而非收敛。

## FROM → TO

```ts
// FROM —— 编译期起将以 TS2305 失败(实测三仓零命中,预期无人受影响)
import { EventSchema } from '@objectstack/spec/automation';
```

- 若你想要的是**事件信封**(校验一条已发出的事件):

```ts
// TO
import { EventSchema } from '@objectstack/spec/kernel';
```

- 若你想要的是**事件类型的声明**(名字、版本、payload 的 JSON Schema):

```ts
// TO —— kernel 侧本来就有的「事件定义」概念
import { EventTypeDefinitionSchema } from '@objectstack/spec/kernel';
```

- 若你想给**状态机**声明它接受的事件:该表面从来不存在 —— 事件类型写在状态节点 `on:` 的记录键上(`on: { APPROVE: 'approved' }`),被删的 schema 从未接入 `StateMachineSchema`,没有替代物也不需要替代物。

## 定级理由(逐条自证,未照抄前例)

定 **major**,因为这是一次**已发布导出名的移除**:外部 `import { EventSchema } from '@objectstack/spec/automation'` 会以 TS2305 编译失败(与 C14 同形)。

同时它是**零元数据迁移**:

- `automation/Event` def 从 `BUILTIN_METADATA_TYPE_SCHEMAS` 元数据根(24 型)**不可达** —— 以 #4650 门禁同款真 Zod 图 BFS 对合并基线实测复核(输出见 PR;同一次 BFS 里 `StateMachineSchema` 本身可达,证明删除是外科式的)。没有任何元数据文档曾被它解析,`authorable-surface.json` 里对应两行(`automation/Event:type` / `automation/Event:schema`)是过度收集的产物,随整 def 出账(#4650 门禁在本 PR 打印的判定是「def no longer emitted」自证路径),**无 tombstone、无 ADR-0087 conversion / migration**。
- 已存 `sys_metadata` 数据、运行时校验行为全部不受影响;`kernel/events/core.zod.ts` 的 `EventSchema` 一字未动。
- JSON Schema 产物中 `automation/Event` 停止发布(`json-schema.manifest.json` 同步删键,#2978 蓄意移除通道)。

## 基线 13 → 12

`dual-source-exports.baseline.json` 删掉 `EventSchema — [./automation (const)] ≠ [./kernel (const)]` 一行,其余 12 行一字未动。
30 changes: 0 additions & 30 deletions content/docs/references/automation/events-core.mdx

This file was deleted.

1 change: 0 additions & 1 deletion content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"approval",
"---More---",
"builtin-node-config",
"events-core",
"flow-function",
"io-node-config",
"retry-policy",
Expand Down
4 changes: 2 additions & 2 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,14 +477,14 @@ tightening (the #4001 "sharing-rule lesson": candidates, not verdicts).
| `validation.zod.ts` | 6 | authorable | **strict as of #4001 batch 3b** — a `z.lazy()` discriminated union, so the one-call conversion does not apply: each of the six variants builds its own `strictObject` from a shared `BASE_VALIDATION_SHAPE`. Closing the base alone would have rejected correctly but suggested from the SHARED keys only, so a typo of a variant's own key (`transtions` → `transitions`) would get no rename. Site count 1 → 6 because the six variants are now object sites in their own right. The ADR-0010 envelope lives in the shared shape, so all six inherit it |
| `field-value.zod.ts` / `seed.zod.ts` | 1+1 | mixed (p) | `seed` is strict (registered-types batch) |

### `automation/` — 87 sites
### `automation/` — 86 sites

| File | Sites | Class | Note |
|---|---|---|---|
| `flow.zod.ts` | 11 | authorable | **strict as of #4001** (4 schemas; `FlowVersionHistorySchema` is runtime — stays tolerant) |
| `sync.zod.ts` / `etl.zod.ts` | 12+10 | authorable (p) | authored pipelines — **candidates** |
| `execution.zod.ts` | 13 | wire | run-state envelopes — never strict. +5 at #4354 (the run-summary family: step metrics / skip reason / per-node / per-gate / the summary itself) — engine-emitted telemetry read by the Console and by operator queries, nobody authors them, so the `wire` verdict covers them unchanged |
| `state-machine.zod.ts` | 7 | authorable (p) | |
| `state-machine.zod.ts` | 6 | authorable (p) | **−1 at #4658**: the orphan `EventSchema` (`{ type, schema }`, an XState-style signal declaration nothing referenced — `StateMachineSchema` names event types as `on:` record keys) was deleted rather than converged with `kernel/events/core.zod.ts`'s envelope `EventSchema`, whose key set it did not intersect (#4535 C6). The remaining 6 sites and their verdict are unchanged |
| `control-flow.zod.ts` | 5 | authorable (p) | validated structurally by `validateControlFlow`. **−1 at #4661**: `RetryPolicySchema` moved out to `shared/retry-policy.zod.ts` — `./automation` and `./system` published the same name for two different declarations (#4411), so the retry policy converged onto one. The site still exists and is still non-strict and authorable; it is simply no longer in a directory this ledger sections. ⚠️ That is a coverage gap worth knowing about: this audit sections `ui/` / `data/` / `automation/` / `security/` / `studio/` only, so a `shared/` shape is unaudited by construction. The tolerance is deliberate here — the `retryDelayMs` → `backoffMs` rename is tombstoned via `retiredKey()` precisely because a non-strict parent would otherwise swallow the old spelling |
| `bpmn-interop.zod.ts` | 5 | wire (p) | interop import shapes |
| `approval.zod.ts` | 4 | authorable | **strict as of #4001 step 3** — all four authoring schemas (node config / approver / escalation / decision-output). The published JSON schema carries `additionalProperties: false` into the Studio form AND `registerFlow()` config validation (#4027/#4040), so an unknown key in an approval node's `config` is rejected at registration too — verified: `z.toJSONSchema` on the strict lazySchema does not throw (#3746 hazard checked) |
Expand Down
1 change: 0 additions & 1 deletion packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2166,7 +2166,6 @@
"ETLTransformationSchema (const)",
"ETLTransformationType (type)",
"ETLTransformationTypeSchema (const)",
"EventSchema (const)",
"ExecutionError (type)",
"ExecutionErrorParsed (type)",
"ExecutionErrorSchema (const)",
Expand Down
2 changes: 0 additions & 2 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2271,8 +2271,6 @@
"automation/ETLTransformation:continueOnError",
"automation/ETLTransformation:name",
"automation/ETLTransformation:type",
"automation/Event:schema",
"automation/Event:type",
"automation/ExecutionError:code",
"automation/ExecutionError:context",
"automation/ExecutionError:executionId",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/docs-import-surface.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"api/MetadataValidateRequest — no type export",
"api/SubscriptionEvent — no type export",
"automation/ApproverType — no type export",
"automation/Event — no type export",
"automation/FlowNodeAction — no type export",
"automation/FlowVariable — no type export",
"automation/GuardRef — no type export",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/dual-source-exports.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
"EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]",
"EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]",
"EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]",
"EventSchema — [./automation (const)] ≠ [./kernel (const)]",
"PackageDependency — [./cloud (type)] ≠ [./kernel (type)]",
"PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]",
"TenantPlan — [./cloud (type)] ≠ [./system (type)]",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/json-schema.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,6 @@
"automation/ETLSyncMode",
"automation/ETLTransformation",
"automation/ETLTransformationType",
"automation/Event",
"automation/ExecutionError",
"automation/ExecutionErrorSeverity",
"automation/ExecutionLog",
Expand Down
143 changes: 143 additions & 0 deletions packages/spec/src/automation/state-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,146 @@ describe('StateMachineSchema', () => {
expect(() => StateMachineSchema.parse(machine)).toThrow();
});
});

// ─── [#4658] `EventSchema` is gone from ./automation — dual-source C6 ────────
//
// `./automation` and `./kernel` both exported an `EventSchema`, for two
// declarations whose key sets did not even intersect:
//
// automation/state-machine.zod.ts (removed) → `{ type, schema }` — an
// XState-style signal DECLARATION ("which events does this machine
// accept"). An orphan: `StateMachineSchema` names event types as the
// record keys of `on:`, and no repo imported it (objectstack / cloud /
// objectui, import-statement-level scan).
// kernel/events/core.zod.ts → `{ id?, name, payload, metadata }` — an
// event-bus ENVELOPE (an emitted event instance).
//
// Converging them would have declared a signal definition to be an envelope —
// a false statement in the contract — so the orphan was deleted instead
// (maintainer ruling on #4658; ledger #4535 C6). The kernel-side analogue of a
// signal *declaration* already exists: `EventTypeDefinitionSchema`, same file.
//
// #4642 established that a compile-time conditional-type pin in this package
// is a no-op (tsconfig excludes `**/*.test.ts`; vitest never enables
// `typecheck`), so the load-bearing pin is the compiler-API test below, with
// anti-vacuity guards; sabotage-verified in the PR (re-adding the export
// turns it red).
describe('[#4658] `EventSchema` is not exported from ./automation', () => {
it('resolves the export surface: no entry but ./kernel declares `EventSchema`', async () => {
const ts = (await import('typescript')).default;
const { resolve, relative, dirname } = await import('node:path');
const { fileURLToPath } = await import('node:url');
const { readFileSync } = await import('node:fs');

const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
// Every public entry point, read from package.json's exports map so a
// future entry cannot silently escape the uniqueness pin below.
const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as {
exports: Record<string, unknown>;
};
const entries: Record<string, string> = {};
for (const sub of Object.keys(pkg.exports)) {
if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts');
else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`);
// './openapi.json' / './package.json' are not TypeScript entry points.
}
// Anti-vacuity: the enumeration must have found the real surface.
expect(Object.keys(entries)).toContain('./automation');
expect(Object.keys(entries)).toContain('./kernel');
expect(Object.keys(entries).length).toBeGreaterThan(10);

const program = ts.createProgram(Object.values(entries), {
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
skipLibCheck: true,
noEmit: true,
});
const checker = program.getTypeChecker();
const unalias = (s: import('typescript').Symbol) =>
s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;

const exportsOf = (sub: string) => {
const sf = program.getSourceFile(entries[sub]);
const moduleSym = sf && checker.getSymbolAtLocation(sf);
// Without this guard a resolution failure would make every assertion
// below pass vacuously — the exact way a gate goes dormant (#4642).
expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy();
return checker.getExportsOfModule(moduleSym!);
};

const originOf = (sym: import('typescript').Symbol, label: string) => {
const decl = unalias(sym).declarations?.[0];
expect(decl, `${label} must have a declaration`).toBeTruthy();
const declFile = decl!.getSourceFile();
return `${relative(specDir, declFile.fileName)}:${
declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1
}`;
};

// 1. The removed side: `./automation` still has a non-trivial surface —
// so the `not.toContain` cannot pass by resolving nothing — and no
// longer names `EventSchema`, while its surviving neighbours stand.
const automationExports = exportsOf('./automation');
expect(automationExports.length, './automation must export a non-trivial surface').toBeGreaterThan(50);
const automationNames = automationExports.map((e) => e.getName());
expect(automationNames).not.toContain('EventSchema');
expect(automationNames).toContain('StateMachineSchema');
expect(automationNames).toContain('TransitionSchema');

// 2. The surviving side: `./kernel` still exports the envelope const and
// its inferred type, declared in kernel/events/core.zod.ts.
const kernelExports = exportsOf('./kernel');
const kernelEventSchema = kernelExports.find((e) => e.getName() === 'EventSchema');
expect(kernelEventSchema, './kernel must export `EventSchema`').toBeTruthy();
const kernelOrigin = originOf(kernelEventSchema!, './kernel EventSchema');
expect(kernelOrigin).toMatch(/^src\/kernel\/events\/core\.zod\.ts:\d+$/);
expect(kernelExports.map((e) => e.getName())).toContain('Event');

// 3. Uniqueness — the dual-source pin proper: across EVERY public entry,
// an export named `EventSchema` must resolve to that ONE declaration.
const holders: string[] = [];
for (const sub of Object.keys(entries)) {
for (const sym of exportsOf(sub).filter((e) => e.getName() === 'EventSchema')) {
holders.push(sub);
expect(
originOf(sym, `${sub} EventSchema`),
`${sub} must resolve \`EventSchema\` to the kernel declaration`,
).toBe(kernelOrigin);
}
}
expect(holders).toContain('./kernel');
expect(holders).not.toContain('./automation');
});

it('keeps the runtime namespaces consistent with the compiler view', async () => {
const automation = await import('./index');
const kernel = await import('../kernel/index');
expect('EventSchema' in automation).toBe(false);
expect('EventSchema' in kernel).toBe(true);

// What the name now unambiguously means: an emitted event INSTANCE.
expect(() =>
kernel.EventSchema.parse({
name: 'user.created',
payload: { id: 'u1' },
metadata: { source: 'test', timestamp: '2026-08-03T00:00:00.000Z' },
}),
).not.toThrow();

// The removed side's shape — a signal DECLARATION — is not what the
// surviving schema accepts: the two concepts were never converged.
expect(() => kernel.EventSchema.parse({ type: 'APPROVE' })).toThrow();
});

it('still authors state-machine event types as `on:` record keys — the surface the orphan never was', () => {
const machine = {
id: 'c6_pin',
initial: 'draft',
states: {
draft: { on: { APPROVE: 'approved' } },
approved: { type: 'final' },
},
};
expect(() => StateMachineSchema.parse(machine)).not.toThrow();
});
});
15 changes: 7 additions & 8 deletions packages/spec/src/automation/state-machine.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,13 @@ export const TransitionSchema = lazySchema(() => z.object({
description: z.string().optional().describe('Human readable description of this rule'),
}));

/**
* Event Definition (Signals)
*/
export const EventSchema = lazySchema(() => z.object({
type: z.string().describe('Event Type (e.g. "APPROVE", "REJECT", "Submit")'),
// Payload validation schema could go here if we want deep validation
schema: z.record(z.string(), z.unknown()).optional().describe('Expected event payload structure'),
}));
// `EventSchema` (XState-style signal declaration `{ type, schema }`) was removed
// here in #4658 (dual-source ledger #4535 C6): nothing in this file — or any
// repo — ever referenced it. Event types on a state machine are the RECORD KEYS
// of `on:` (plain strings), not declared signal objects. The platform's one
// `EventSchema` is the event-bus envelope in `kernel/events/core.zod.ts`; the
// kernel-side analogue of a signal *declaration* is `EventTypeDefinitionSchema`
// in the same file.

export type ActionRef = z.infer<typeof ActionRefSchema>;
export type Transition = z.infer<typeof TransitionSchema>;
Expand Down
Loading