Skip to content

Commit a68e3d3

Browse files
committed
feat(spec): export SEED_WRITE_EXECUTION_CONTEXT and bind all three seeders to it
The seed-write execution context — `isSystem`, `skipTriggers`, `seedReplay` — had no exported spelling, so every seeder held a private copy: three on `main` (`SeedLoaderService.SEED_OPTIONS`, `AppPlugin`'s `SEED_WRITE_OPTIONS`, `@objectstack/verify`'s `SEED_CONTEXT`), with nothing holding them equal. A constant whose divergence re-opens a boot-wedging self-trigger loop (#3760) is a kernel semantic, not a local detail. `@objectstack/spec/kernel` now declares it once, beside the `ExecutionContext` contract whose keys it sets, and all three sites read it. The exported shape is the INNER context, not an options bag: the bag is an options envelope whose type differs per engine method, and it is the convenience bundle triage ruled out. Value byte-identical to all three previous copies; no seed behaviour moves. Fixes #17178 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
1 parent 7173d7d commit a68e3d3

6 files changed

Lines changed: 179 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/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)