Skip to content

Commit bdb247d

Browse files
claude[bot]claude
andauthored
feat(spec): export SEED_WRITE_EXECUTION_CONTEXT and bind all three seeders to it (#17718)
Fixes #17178 `Clause-②: yes` — this PR's whole purpose is to widen a published package's public surface, so a contract review is owed. The verdict is carried on both carriers: the claim comment on the card and this body. ## What changed The seed-write execution context — `isSystem`, `skipTriggers`, `seedReplay` — had no exported spelling, so every seeder held a private copy. `@objectstack/spec/kernel` now declares it once as `SEED_WRITE_EXECUTION_CONTEXT`, beside the `ExecutionContext` contract whose keys it sets, and all three former copies read it. | site | before | after | |:---|:---|:---| | `packages/metadata-protocol/src/seed-loader.ts` | `SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } }` | `SEED_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT }` | | `packages/runtime/src/app-plugin.ts` | `SEED_WRITE_OPTIONS = { context: { ...the same three flags... } }` | `SEED_WRITE_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT }` | | `packages/verify/src/handle.ts` | `SEED_CONTEXT = { ...the same three flags... }` | `SEED_CONTEXT = SEED_WRITE_EXECUTION_CONTEXT` | Value byte-identical to all three previous copies. No flag changes meaning, no seed path changes what it writes. Occurrences of the three-flag literal in those three files went 1 each to 0 each; the only object literal spelling the triple in non-test production code is now the canonical declaration. ## The shape fork, and what settled it The card and the triage comment both left the shape open: the inner `ExecutionContext`, or the whole `{ context: ... }` options bag. It is settled here from the triage ruling plus the call sites, not picked on taste. 1. **The ruling names the bag.** Triage: 「Exporting from a published package widens its public surface ⇒ declare … and keep the exported shape minimal — ⛔ do not export a convenience bundle around it.」 The `{ context: ... }` bag *is* a convenience bundle around the execution context — it is the options envelope, not the posture. 2. **No site needs an adapter either way, so read what the sites are ABOUT.** All three ultimately hand `{ context: X }` to `insert`; `verify` already spells that bag inline at its call site (`ql.insert(object, rows, { context: SEED_CONTEXT })`). Every one of the three docblocks explains the three *flags*; not one of them explains the envelope. The posture is the shared thing; the envelope is per-call. 3. **Type honesty.** The inner value has a declared spec type with a Zod schema behind it, so it can be parsed and pinned. The bag has no single type: the options parameter differs per engine method, so freezing one bag onto the protocol surface would serve `insert` and no other operation — while the inner context composes into update, delete and read options as well. ⛔ **Deliberately not exported:** any options bag, factory or helper around it. ## The pin `packages/spec/src/kernel/execution-context.test.ts` gains six cases (`[#17178]`): - the whole value passes `ExecutionContextSchema.safeParse` — a valid context, not a dialect; - exactly the three keys, nothing more and nothing less; - one named case per flag, each carrying why it is load-bearing, so "one flag looks redundant, drop it" goes red with the reason rather than silently; - the name is reachable on exactly one public entry point (`./kernel`) and has exactly one holder — the minimal-widening claim, asserted by mechanism, with anti-vacuity guards on the resolved surface first. **What the pin does NOT do, stated rather than implied.** It cannot catch a *fourth* site re-spelling the triple in some future package: that is a repo-wide source invariant, and its natural home is a repo-root `check:*` gate, which is outside this card's declared file surface. The three copies this card was filed about are gone by construction (they reference the export), so nothing needs holding equal between them; a fourth-copy gate is a separate, additive piece of work and is left to the maintainer's call rather than smuggled in here. ## Verification Gate union derived from the diff by `scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, then reconciled with `--ran` carrying an exit code per family (captured before any pipe, per `cmd > log 2>&1; EXIT=$?`). Measured on `5a307947`. ``` dispatch-gates --ran: 86 derived family(ies) accounted for — 84 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN 84 measured: exit 0 2 NOT MEASURED (exit 3 = PREREQUISITE NOT MET, neither pass nor failure): - pnpm check:dual-build-cjs-loads (needs every package built; 35 have no dist here) - pnpm check:type-check-debt (--re-measure refuses without the full closure built) both need `turbo run build --filter='./packages/*' ...`, which CI does before those steps ``` Build, tests and typechecks, each through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0` on every hold): ``` pnpm --filter @objectstack/spec build VERDICT command-exit 0 pnpm --workspace-concurrency=2 --filter '@objectstack/verify^...' build VERDICT command-exit 0 pnpm --filter @objectstack/spec typecheck exit 0 pnpm --filter @objectstack/metadata-protocol typecheck exit 0 pnpm --filter @objectstack/runtime typecheck exit 0 pnpm --filter @objectstack/verify typecheck exit 0 pnpm --filter @objectstack/spec test 473 files / 13441 tests passed exit 0 pnpm --filter @objectstack/metadata-protocol test 176 files / 2529 passed, 12 skipped exit 0 pnpm --filter @objectstack/runtime test 259 files / 3613 tests passed exit 0 pnpm --filter @objectstack/verify test 14 files / 103 tests passed exit 0 ``` `check:api-surface` read the widening as **`0 breaking (removed/narrowed), 1 added`** and `api-surface/kernel.json` plus `export-origins/kernel.json` each moved by exactly one line. `pnpm --filter @objectstack/spec check:generated` is green on all 15 artifacts. **eslint: the FULL population, not a narrowing.** `eslint . --no-inline-config --format json` — **6635 files linted, 0 errors, 0 warnings, exit 0**. The count is read from eslint's own JSON output, not estimated, so no narrowing argument is needed. ### Reverse verification (one-shot, restored; both legs proven on disk) **Leg 1 — the pin can fail.** Deleting `skipTriggers: true` from the canonical (anchor occurrence count 1 to 0, `git diff --stat` showing the single deletion) turned the pin red on exactly the two cases that should move, and on nothing else: ``` FAIL src/kernel/execution-context.test.ts > [#17178] ... > sets exactly the three seed flags and nothing else FAIL src/kernel/execution-context.test.ts > [#17178] ... > sets `skipTriggers` — `isSystem` alone does NOT suppress trigger dispatch (#3760) Tests 2 failed | 25 passed (27) vitest exit 1 ``` Restored with `git checkout HEAD -- path` (never the bare form, which restores from the index): `git hash-object` equals the HEAD blob hash `edc468ad`, and `git diff HEAD` is empty. **Leg 2 — the consumers really read the REBUILT declarations, not a cached artifact.** Stripping the symbol from the built `packages/spec/dist/kernel/index.d.ts` and `.d.mts` (mentions 1 to 0 in each) turned the consumer typecheck red naming it: ``` src/handle.ts(50,10): error TS2305: Module '"@objectstack/spec/kernel"' has no exported member 'SEED_WRITE_EXECUTION_CONTEXT'. tsc exit 1 ``` Both files restored byte-exact (sha256 match, `73a3133f…` and `8f9cd586…`), after which the same typecheck exits 0. The ablation script carried `trap restore EXIT INT TERM` with absolute paths resolved from `git rev-parse --show-toplevel`; no temporary file or mutation survives in the diff. ## Changeset `minor` on `@objectstack/spec`, `patch` on the three consumers. `minor` is not a choice here: the standing ruling in `.github/workflows/pr-automation.yml` reads 「A purely additive widening of a published package's public surface takes at least `minor`. The commit type may raise a bump but never lower it below what the act requires.」 and `check:api-surface` measured this as purely additive. The three consumers ship changed `dist` with unchanged public surfaces, so `patch`. Not breaking, so no ADR-0087 disposition marker is owed. ## Acceptance notes - **`SYSTEM_CTX` is the same duplication class one level out, and is NOT touched here.** `{ isSystem: true, positions: [], permissions: [] }` is a private constant in three files (`plugins/plugin-approvals/src/approval-node.ts`, `plugins/plugin-approvals/src/approval-service.ts`, `plugins/plugin-sharing/src/sharing-rule-service.ts`), with two more shapes nearby (`verify`'s `SYSTEM_CONTEXT`, `service-knowledge`'s `adminCtx`). All measured identical today — no divergence, no contract violated, no authoring trap — so it is noted, not filed. The successor who would carry it is a follow-up on single-sourcing the plain system context; `packages/core/src/security/assemble-execution-context.ts` is the module that already owns the "one assembly of an `ExecutionContext`" principle and names this exact defect family (#6071 / #6206 / #6551). - **The shared singleton is not frozen at runtime, deliberately.** `Object.freeze` would be a real improvement against aliasing, and there is precedent for it in this package, but it is a behaviour change on the seed write path if any layer ever mutates a context in place — and the card forbids changes to seed behaviour. The type is `as const satisfies ExecutionContext`, so every consumer is compile-blocked from mutating through the readonly properties. Noted as a boundary, not decided here. - **The fourth-copy gate** described under "The pin" is a costed proposal, not an omission: a repo-root `check:*` script plus its workflow step, all outside this card's declared file surface. ## 维护者速读(草稿) **改了什么** —— 种子写入用的执行上下文(`isSystem` / `skipTriggers` / `seedReplay`)此前在三个包里各抄了一份私有常量,谁都不知道彼此。现在 `@objectstack/spec/kernel` 导出唯一一份 `SEED_WRITE_EXECUTION_CONTEXT`,三处旧抄本改为读它。值逐字节相同,种子行为一处未动。 **为什么改** —— 这三个标志的组合是有承载的:`skipTriggers` 才是压住"新建时"自动化的那一个,`isSystem` 单独并不压住派发。曾经有一条种子路径漏了它,于是一边跑着自动化一边另一边压着,自触发环路把首次启动卡死(#3760)。抄本一旦分叉,这个卡死缺陷就重新打开 —— 所以这是内核缺口,不是风格问题。 **风险与代价(含回滚)** —— 风险低:纯增量导出,`check:api-surface` 读数为 `0 breaking, 1 added`;四个受影响包的全量测试与类型检查全绿;全仓 eslint 6635 文件零告警。代价是 `@objectstack/spec` 的公开面多了一个名字(故走 `minor`,并欠一次契约复审)。回滚即 revert 本 PR:三处站点回到各自的私有抄本,无数据迁移、无存量元数据受影响。 **席位意见** —— **你要做的** —— ① 裁决导出形状:本 PR 选了内层 `ExecutionContext`(理由见上方 "The shape fork"),若你更想要整只 options bag,说一声即可换。② 决定是否要那条"第四份抄本"仓级门禁 —— 它落在本卡围栏之外,故未写。③ 这是 Clause-② 的契约复审对象,已挂 `needs:contract-review`。 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd25e89 commit bdb247d

8 files changed

Lines changed: 181 additions & 40 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/verify': patch
6+
---
7+
8+
`@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads
9+
10+
The execution context a seed write must use — `isSystem`, `skipTriggers`,
11+
`seedReplay` — had **no exported form**, so every seeder held a private copy of
12+
it and nothing held the copies equal. There were three on `main`:
13+
`SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`),
14+
`SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock
15+
already recorded that it "mirrors" the first) and `SEED_CONTEXT`
16+
(`@objectstack/verify`'s fixture writer, which spelled it a third time
17+
specifically because the runtime kept its copy module-private).
18+
19+
**Why a shared constant rather than three accurate copies.** `skipTriggers` is
20+
what suppresses "on create" automation for seed rows, and `isSystem` alone does
21+
**not** suppress dispatch. A seed path that lost that flag once seeded with
22+
automation live while the main path had it suppressed — a self-trigger loop that
23+
wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging
24+
defect is a kernel semantic, not a local detail.
25+
26+
**What is exported, and what deliberately is not.** The **inner**
27+
`ExecutionContext` value, and nothing wrapped around it:
28+
29+
```ts
30+
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
31+
32+
await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT });
33+
```
34+
35+
The `{ context: … }` options bag stays at the call site. It is what all three
36+
sites ultimately hand to `insert`, but it is an options envelope rather than the
37+
posture: its type differs per engine method, so freezing one bag onto the
38+
protocol surface would serve `insert` and no other operation, and it is
39+
precisely the convenience bundle this export is not.
40+
41+
**No behaviour change.** The value is byte-identical to all three previous
42+
copies, the three flags keep their existing meanings, and no seed path changes
43+
what it writes or how. The three former copies now read this export, so the two
44+
option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify`
45+
context is the export itself.
46+
47+
**Additive, so `minor` on `@objectstack/spec`**: one new name on the existing
48+
`./kernel` entry point, no existing export removed, renamed or narrowed. The
49+
three consumers take `patch` — their published `dist` changes (an import edge,
50+
and the constant now resolves through `@objectstack/spec/kernel`) while their
51+
own public surfaces do not move.

packages/metadata-protocol/src/seed-loader.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
Seed,
1515
} from '@objectstack/spec/data';
1616
import { SeedLoaderConfigSchema, isMultiValueField } from '@objectstack/spec/data';
17+
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
1718
import { resolveSeedRecord } from '@objectstack/formula';
1819
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core';
1920
// [#8442] The repo's ONE recogniser for "this throw is a record-validation
@@ -2095,32 +2096,20 @@ export class SeedLoaderService implements ISeedLoaderService {
20952096
// ==========================================================================
20962097

20972098
/**
2098-
* Seed writes always run as a privileged system context. This bypasses
2099-
* RBAC checks (so seeds can target system tables like `sys_*`) and
2100-
* disables the SecurityPlugin's auto-injection of `organization_id` /
2101-
* `owner_id` — seeds either declare those fields explicitly per
2102-
* record, or are intentionally cross-tenant / global.
2103-
*
2104-
* `skipTriggers` suppresses record-change AUTOMATION (autolaunched flow
2105-
* triggers) for seed writes: a package's seed is pre-existing END-STATE
2106-
* reference/sample data, not a stream of user events, so firing
2107-
* on-create/on-update flows (notifications, escalations, assignments,
2108-
* approvals) for it is semantically wrong and dangerous — a self-triggering
2109-
* flow can loop and wedge the whole first-boot (2026-07-06 incident).
2110-
* Lifecycle HOOKS (derived/default fields, validation) still run.
2099+
* The seed-write options every write in this loader uses — the shared
2100+
* {@link SEED_WRITE_EXECUTION_CONTEXT} posture, wrapped in the options bag
2101+
* the engine's write methods take.
21112102
*
2112-
* `seedReplay` (#3433) tells the engine this is curated seed data so the
2113-
* object's `state_machine` validation rule is skipped — both the
2114-
* `initialStates` entry-point check on insert and the transition check on
2115-
* update. A seed is a snapshot of established facts (a `completed` project, a
2116-
* `closed_won` opportunity), not a record walking its lifecycle, so the FSM
2117-
* entry/transition guards do not apply. Without this a declared
2118-
* `initialStates` silently rejects every mid-lifecycle seed row and cascades
2119-
* its master-detail children — the "installed but no data" failure for
2120-
* showcase and every marketplace template. All OTHER validation (field
2121-
* shape, `format`, `cross_field`, `script`, `json_schema`) still runs.
2103+
* The posture itself (system-elevated, automation suppressed, state-machine
2104+
* exempt) and why each flag is load-bearing are documented once, on that
2105+
* export in `@objectstack/spec/kernel`. What is specific to this loader:
2106+
* `isSystem` is what lets a seed target system tables like `sys_*` and what
2107+
* disables the SecurityPlugin's auto-injection of `organization_id` /
2108+
* `owner_id`, so seeds either declare those fields explicitly per record or
2109+
* are intentionally cross-tenant / global. Lifecycle HOOKS
2110+
* (derived/default fields, validation) still run.
21222111
*/
2123-
private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } } as const;
2112+
private static readonly SEED_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT } as const;
21242113

21252114
/**
21262115
* The engine write {@link writeRecoveringSummary} guards, as a NAMED callee.

packages/runtime/src/app-plugin.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { loadDisabledPackageIds } from './package-state-store.js';
2323
import type { IJobService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts';
2424
import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation';
2525
import { readServiceSelfInfo } from '@objectstack/spec/api';
26+
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
2627
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
2728
import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js';
2829
import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js';
@@ -32,16 +33,20 @@ import { countServerTiming, SEMCONV } from '@objectstack/observability';
3233
import { resolveMetrics } from './observability/observability-service-plugin.js';
3334

3435
/**
35-
* The write options every seed insert must use — mirrors
36-
* `SeedLoaderService.SEED_OPTIONS`. `skipTriggers` is the load-bearing part:
37-
* seed rows are pre-existing end-state data, not user events, so firing
38-
* "on create" automation for them is semantically wrong and was the vector for
39-
* a self-trigger loop that wedged first boot. `isSystem` alone does NOT suppress
40-
* dispatch — only `skipTriggers` does — so the two basic-insert fallbacks below
41-
* used to seed with automation live while the main path had it suppressed
42-
* (#3760).
36+
* The write options every seed insert must use — the shared
37+
* {@link SEED_WRITE_EXECUTION_CONTEXT} posture, wrapped in the options bag
38+
* `IObjectQLEngine.insert` takes. It no longer MIRRORS
39+
* `SeedLoaderService.SEED_OPTIONS`; both now read the same export, so the two
40+
* cannot drift apart (#17178).
41+
*
42+
* `skipTriggers` is the load-bearing part: seed rows are pre-existing end-state
43+
* data, not user events, so firing "on create" automation for them is
44+
* semantically wrong and was the vector for a self-trigger loop that wedged
45+
* first boot. `isSystem` alone does NOT suppress dispatch — only `skipTriggers`
46+
* does — so the two basic-insert fallbacks below used to seed with automation
47+
* live while the main path had it suppressed (#3760).
4348
*/
44-
const SEED_WRITE_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } } as const;
49+
const SEED_WRITE_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT } as const;
4550

4651
/**
4752
* Optional per-project context attached when AppPlugin is instantiated by the

packages/spec/api-surface/kernel.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@
376376
"SBOMEntrySchema (const)",
377377
"SBOMParsed (type)",
378378
"SBOMSchema (const)",
379+
"SEED_WRITE_EXECUTION_CONTEXT (const)",
379380
"SandboxConfig (type)",
380381
"SandboxConfigParsed (type)",
381382
"SandboxConfigSchema (const)",

packages/spec/export-origins/kernel.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@
374374
"SBOMEntrySchema": "src/kernel/plugin-security.zod.ts#SBOMEntrySchema (const)",
375375
"SBOMParsed": "src/kernel/plugin-security.zod.ts#SBOMParsed (type)",
376376
"SBOMSchema": "src/kernel/plugin-security.zod.ts#SBOMSchema (const)",
377+
"SEED_WRITE_EXECUTION_CONTEXT": "src/kernel/execution-context.zod.ts#SEED_WRITE_EXECUTION_CONTEXT (const)",
377378
"SandboxConfig": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfig (type)",
378379
"SandboxConfigParsed": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigParsed (type)",
379380
"SandboxConfigSchema": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigSchema (const)",

packages/spec/src/kernel/execution-context.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2-
import { ExecutionContextSchema } from './execution-context.zod';
2+
import { ExecutionContextSchema, SEED_WRITE_EXECUTION_CONTEXT } from './execution-context.zod';
3+
import { EXPORT_ENTRY_POINTS, exportNamesOf, holdersOf } from '../../scripts/lib/export-origins-testkit';
34

45
describe('ExecutionContextSchema', () => {
56
it('should accept empty context (all optional)', () => {
@@ -199,3 +200,57 @@ describe('ExecutionContextSchema.preserveAudit — the published description (#6
199200
expect(description).toMatch(/audit|updated_at/i);
200201
});
201202
});
203+
204+
205+
// ─── [#17178] SEED_WRITE_EXECUTION_CONTEXT — one spelling of the seed posture ─
206+
//
207+
// The seed-write context used to be a PRIVATE constant in three places
208+
// (`SeedLoaderService.SEED_OPTIONS`, `AppPlugin`'s `SEED_WRITE_OPTIONS`,
209+
// `@objectstack/verify`'s `SEED_CONTEXT`), and nothing held the three equal.
210+
// They now all read this export, so the copies are gone by construction; what
211+
// this block holds is the VALUE they read and the SURFACE it is read through.
212+
//
213+
// Why the value is pinned and not just documented: divergence here re-opens a
214+
// boot-wedging defect. `skipTriggers` is what suppresses "on create" automation
215+
// for seed rows — `isSystem` alone does NOT suppress dispatch — and a seed path
216+
// that lost it once seeded with automation live while the main path had it
217+
// suppressed, a self-trigger loop that wedged first boot (#3760). So "one flag
218+
// looks redundant, drop it" is exactly the edit that must go red.
219+
describe('[#17178] SEED_WRITE_EXECUTION_CONTEXT', () => {
220+
it('is a valid ExecutionContext — the WHOLE value parses, not merely its key names', () => {
221+
const parsed = ExecutionContextSchema.safeParse(SEED_WRITE_EXECUTION_CONTEXT);
222+
expect(parsed.success, JSON.stringify('error' in parsed ? parsed.error : {})).toBe(true);
223+
});
224+
225+
it('sets exactly the three seed flags and nothing else', () => {
226+
expect(Object.keys(SEED_WRITE_EXECUTION_CONTEXT).sort()).toEqual([
227+
'isSystem',
228+
'seedReplay',
229+
'skipTriggers',
230+
]);
231+
});
232+
233+
it('sets `skipTriggers` — `isSystem` alone does NOT suppress trigger dispatch (#3760)', () => {
234+
expect(SEED_WRITE_EXECUTION_CONTEXT.skipTriggers).toBe(true);
235+
});
236+
237+
it('sets `seedReplay` — the state_machine exemption a mid-lifecycle seed row needs (#3433)', () => {
238+
expect(SEED_WRITE_EXECUTION_CONTEXT.seedReplay).toBe(true);
239+
});
240+
241+
it('sets `isSystem` — seeds target `sys_*` and declare their own tenancy columns', () => {
242+
expect(SEED_WRITE_EXECUTION_CONTEXT.isSystem).toBe(true);
243+
});
244+
245+
it('is reachable on exactly one public entry point — `./kernel`, the minimal widening', () => {
246+
// Anti-vacuity first: the resolved surface must be the real one, or
247+
// "exactly one holder" could pass by resolving nothing.
248+
expect(EXPORT_ENTRY_POINTS, 'exports map must include ./kernel').toContain('./kernel');
249+
const kernelNames = exportNamesOf('./kernel');
250+
expect(kernelNames.length, './kernel must export a non-trivial surface').toBeGreaterThan(40);
251+
expect(kernelNames, 'a neighbour that must stand').toContain('ExecutionContextSchema');
252+
253+
expect(kernelNames).toContain('SEED_WRITE_EXECUTION_CONTEXT');
254+
expect(holdersOf('SEED_WRITE_EXECUTION_CONTEXT')).toEqual(['./kernel']);
255+
});
256+
});

packages/spec/src/kernel/execution-context.zod.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,3 +433,42 @@ export type ExecutionContext = z.input<typeof ExecutionContextSchema>;
433433
/** Post-parse shape of {@link ExecutionContext} — defaults applied, transforms run (ADR-0122). */
434434
export type ExecutionContextParsed = z.infer<typeof ExecutionContextSchema>;
435435

436+
437+
/**
438+
* The execution context EVERY seed write must use — the one spelling of the
439+
* seed-write posture, so a seeder reads it instead of re-deriving it.
440+
*
441+
* Three flags, and the combination is load-bearing rather than cosmetic:
442+
*
443+
* - {@link skipTriggers} is what suppresses record-change AUTOMATION for seed
444+
* rows. A seed is pre-existing END-STATE data, not a stream of user events,
445+
* so firing on-create/on-update flows (notifications, escalations,
446+
* assignments, approvals) for it is semantically wrong. **{@link isSystem}
447+
* alone does NOT suppress dispatch** — only this flag does — and a seed
448+
* path that omitted it once seeded with automation live while the main path
449+
* had it suppressed, a self-trigger loop that wedged first boot (#3760).
450+
* - {@link isSystem} elevates past permission/RLS enforcement and disables
451+
* the SecurityPlugin's auto-injection of `organization_id` / `owner_id`:
452+
* seeds declare those per record, or are intentionally global.
453+
* - {@link seedReplay} (#3433) exempts the write from the object's
454+
* `state_machine` rule, entry check and transitions both, because a seed is
455+
* a snapshot of established facts. Every OTHER validation still runs.
456+
*
457+
* Because divergence between copies of this value re-opens a boot-wedging
458+
* defect, it is declared HERE — beside the {@link ExecutionContext} contract
459+
* whose keys it sets — rather than privately per seeder (#17178). It is the
460+
* INNER context, deliberately: a seeder composes it into whatever options bag
461+
* its call takes (`{ context: SEED_WRITE_EXECUTION_CONTEXT }`), on insert or on
462+
* any other operation. ⛔ No options-bag or helper wrapper is exported around
463+
* it — the bag belongs to the call site, the posture belongs here.
464+
*
465+
* Known readers: `SeedLoaderService.SEED_OPTIONS`
466+
* (`@objectstack/metadata-protocol`), `AppPlugin`'s `SEED_WRITE_OPTIONS`
467+
* (`@objectstack/runtime`, replaying a stack's declared `data[]`) and
468+
* `@objectstack/verify`'s `seed(object, rows)` fixture writer.
469+
*/
470+
export const SEED_WRITE_EXECUTION_CONTEXT = {
471+
isSystem: true,
472+
skipTriggers: true,
473+
seedReplay: true,
474+
} as const satisfies ExecutionContext;

packages/verify/src/handle.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
// exists to retire, and would stay green when the engine changes.
4848

4949
import { HttpDispatcher, type ObjectKernel, type HttpProtocolContext } from '@objectstack/runtime';
50-
import type { ExecutionContext } from '@objectstack/spec/kernel';
50+
import { SEED_WRITE_EXECUTION_CONTEXT, type ExecutionContext } from '@objectstack/spec/kernel';
5151
import type { ValidateDataResponse } from '@objectstack/spec/api';
5252
import type { AutomationResult } from '@objectstack/spec/contracts';
5353
import type { ServiceObject } from '@objectstack/spec/data';
@@ -238,12 +238,12 @@ export interface VerifyHandle {
238238
const API_PREFIX = '/api/v1';
239239

240240
/**
241-
* The write context `AppPlugin` uses to replay a stack's declared `data[]`
242-
* (`packages/runtime/src/app-plugin.ts`, `SEED_WRITE_OPTIONS`). Spelled here
243-
* because the runtime keeps that constant module-private; the three flags are
244-
* the engine's own documented `ExecutionContext` keys, not a dialect.
241+
* The write context `AppPlugin` uses to replay a stack's declared `data[]`
242+
* read from the kernel's own {@link SEED_WRITE_EXECUTION_CONTEXT} rather than
243+
* re-spelled here, so this fixture writer cannot drift from the seed posture
244+
* the platform actually replays with (#17178).
245245
*/
246-
const SEED_CONTEXT: ExecutionContext = { isSystem: true, skipTriggers: true, seedReplay: true } as ExecutionContext;
246+
const SEED_CONTEXT: ExecutionContext = SEED_WRITE_EXECUTION_CONTEXT;
247247
const SYSTEM_CONTEXT: ExecutionContext = { isSystem: true } as ExecutionContext;
248248

249249
function refusalFrom(status: number, body: unknown, fallback: string): VerifyRefusal {

0 commit comments

Comments
 (0)