diff --git a/.changeset/17178-seed-write-execution-context-export.md b/.changeset/17178-seed-write-execution-context-export.md new file mode 100644 index 0000000000..1ec0b5f451 --- /dev/null +++ b/.changeset/17178-seed-write-execution-context-export.md @@ -0,0 +1,51 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': patch +'@objectstack/runtime': patch +'@objectstack/verify': patch +--- + +`@objectstack/spec/kernel` exports `SEED_WRITE_EXECUTION_CONTEXT`, the one spelling of the seed-write posture every seeder now reads + +The execution context a seed write must use — `isSystem`, `skipTriggers`, +`seedReplay` — had **no exported form**, so every seeder held a private copy of +it and nothing held the copies equal. There were three on `main`: +`SeedLoaderService.SEED_OPTIONS` (`@objectstack/metadata-protocol`), +`SEED_WRITE_OPTIONS` (`@objectstack/runtime`'s `AppPlugin`, whose own docblock +already recorded that it "mirrors" the first) and `SEED_CONTEXT` +(`@objectstack/verify`'s fixture writer, which spelled it a third time +specifically because the runtime kept its copy module-private). + +**Why a shared constant rather than three accurate copies.** `skipTriggers` is +what suppresses "on create" automation for seed rows, and `isSystem` alone does +**not** suppress dispatch. A seed path that lost that flag once seeded with +automation live while the main path had it suppressed — a self-trigger loop that +wedged first boot (#3760). A constant whose divergence re-opens a boot-wedging +defect is a kernel semantic, not a local detail. + +**What is exported, and what deliberately is not.** The **inner** +`ExecutionContext` value, and nothing wrapped around it: + +```ts +import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; + +await ql.insert(object, rows, { context: SEED_WRITE_EXECUTION_CONTEXT }); +``` + +The `{ context: … }` options bag stays at the call site. It is what all three +sites ultimately hand to `insert`, but it is an options envelope rather than the +posture: its type differs per engine method, so freezing one bag onto the +protocol surface would serve `insert` and no other operation, and it is +precisely the convenience bundle this export is not. + +⛔ **No behaviour change.** The value is byte-identical to all three previous +copies, the three flags keep their existing meanings, and no seed path changes +what it writes or how. The three former copies now read this export, so the two +option bags are `{ context: SEED_WRITE_EXECUTION_CONTEXT }` and the `verify` +context is the export itself. + +**Additive, so `minor` on `@objectstack/spec`**: one new name on the existing +`./kernel` entry point, no existing export removed, renamed or narrowed. The +three consumers take `patch` — their published `dist` changes (an import edge, +and the constant now resolves through `@objectstack/spec/kernel`) while their +own public surfaces do not move. diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index d15cac83ef..e383a91694 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -14,6 +14,7 @@ import type { Seed, } from '@objectstack/spec/data'; import { SeedLoaderConfigSchema, isMultiValueField } from '@objectstack/spec/data'; +import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { resolveSeedRecord } from '@objectstack/formula'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult, runWithAdvisoryAggregation, type AdvisoryGroup } from '@objectstack/core'; // [#8442] The repo's ONE recogniser for "this throw is a record-validation @@ -2095,32 +2096,20 @@ export class SeedLoaderService implements ISeedLoaderService { // ========================================================================== /** - * Seed writes always run as a privileged system context. This bypasses - * RBAC checks (so seeds can target system tables like `sys_*`) and - * disables the SecurityPlugin's auto-injection of `organization_id` / - * `owner_id` — seeds either declare those fields explicitly per - * record, or are intentionally cross-tenant / global. - * - * `skipTriggers` suppresses record-change AUTOMATION (autolaunched flow - * triggers) for seed writes: a package's seed is pre-existing END-STATE - * reference/sample data, not a stream of user events, so firing - * on-create/on-update flows (notifications, escalations, assignments, - * approvals) for it is semantically wrong and dangerous — a self-triggering - * flow can loop and wedge the whole first-boot (2026-07-06 incident). - * Lifecycle HOOKS (derived/default fields, validation) still run. + * The seed-write options every write in this loader uses — the shared + * {@link SEED_WRITE_EXECUTION_CONTEXT} posture, wrapped in the options bag + * the engine's write methods take. * - * `seedReplay` (#3433) tells the engine this is curated seed data so the - * object's `state_machine` validation rule is skipped — both the - * `initialStates` entry-point check on insert and the transition check on - * update. A seed is a snapshot of established facts (a `completed` project, a - * `closed_won` opportunity), not a record walking its lifecycle, so the FSM - * entry/transition guards do not apply. Without this a declared - * `initialStates` silently rejects every mid-lifecycle seed row and cascades - * its master-detail children — the "installed but no data" failure for - * showcase and every marketplace template. All OTHER validation (field - * shape, `format`, `cross_field`, `script`, `json_schema`) still runs. + * The posture itself (system-elevated, automation suppressed, state-machine + * exempt) and why each flag is load-bearing are documented once, on that + * export in `@objectstack/spec/kernel`. What is specific to this loader: + * `isSystem` is what lets a seed target system tables like `sys_*` and what + * disables the SecurityPlugin's auto-injection of `organization_id` / + * `owner_id`, so seeds either declare those fields explicitly per record or + * are intentionally cross-tenant / global. Lifecycle HOOKS + * (derived/default fields, validation) still run. */ - private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } } as const; + private static readonly SEED_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT } as const; /** * The engine write {@link writeRecoveringSummary} guards, as a NAMED callee. diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 228be08e53..ece2a7fc95 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -23,6 +23,7 @@ import { loadDisabledPackageIds } from './package-state-store.js'; import type { IJobService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; import { normalizeFlowFunctionEntry, type NormalizedFlowFunction } from '@objectstack/spec/automation'; import { readServiceSelfInfo } from '@objectstack/spec/api'; +import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js'; @@ -32,16 +33,20 @@ import { countServerTiming, SEMCONV } from '@objectstack/observability'; import { resolveMetrics } from './observability/observability-service-plugin.js'; /** - * The write options every seed insert must use — mirrors - * `SeedLoaderService.SEED_OPTIONS`. `skipTriggers` is the load-bearing part: - * seed rows are pre-existing end-state data, not user events, so firing - * "on create" automation for them is semantically wrong and was the vector for - * a self-trigger loop that wedged first boot. `isSystem` alone does NOT suppress - * dispatch — only `skipTriggers` does — so the two basic-insert fallbacks below - * used to seed with automation live while the main path had it suppressed - * (#3760). + * The write options every seed insert must use — the shared + * {@link SEED_WRITE_EXECUTION_CONTEXT} posture, wrapped in the options bag + * `IObjectQLEngine.insert` takes. It no longer MIRRORS + * `SeedLoaderService.SEED_OPTIONS`; both now read the same export, so the two + * cannot drift apart (#17178). + * + * `skipTriggers` is the load-bearing part: seed rows are pre-existing end-state + * data, not user events, so firing "on create" automation for them is + * semantically wrong and was the vector for a self-trigger loop that wedged + * first boot. `isSystem` alone does NOT suppress dispatch — only `skipTriggers` + * does — so the two basic-insert fallbacks below used to seed with automation + * live while the main path had it suppressed (#3760). */ -const SEED_WRITE_OPTIONS = { context: { isSystem: true, skipTriggers: true, seedReplay: true } } as const; +const SEED_WRITE_OPTIONS = { context: SEED_WRITE_EXECUTION_CONTEXT } as const; /** * Optional per-project context attached when AppPlugin is instantiated by the diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index 9ad8e331a2..c583a64ed3 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -376,6 +376,7 @@ "SBOMEntrySchema (const)", "SBOMParsed (type)", "SBOMSchema (const)", + "SEED_WRITE_EXECUTION_CONTEXT (const)", "SandboxConfig (type)", "SandboxConfigParsed (type)", "SandboxConfigSchema (const)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index ebe9e91088..46f5918537 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -374,6 +374,7 @@ "SBOMEntrySchema": "src/kernel/plugin-security.zod.ts#SBOMEntrySchema (const)", "SBOMParsed": "src/kernel/plugin-security.zod.ts#SBOMParsed (type)", "SBOMSchema": "src/kernel/plugin-security.zod.ts#SBOMSchema (const)", + "SEED_WRITE_EXECUTION_CONTEXT": "src/kernel/execution-context.zod.ts#SEED_WRITE_EXECUTION_CONTEXT (const)", "SandboxConfig": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfig (type)", "SandboxConfigParsed": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigParsed (type)", "SandboxConfigSchema": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigSchema (const)", diff --git a/packages/spec/src/kernel/execution-context.test.ts b/packages/spec/src/kernel/execution-context.test.ts index 4fcdf01865..7c023114d8 100644 --- a/packages/spec/src/kernel/execution-context.test.ts +++ b/packages/spec/src/kernel/execution-context.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { ExecutionContextSchema } from './execution-context.zod'; +import { ExecutionContextSchema, SEED_WRITE_EXECUTION_CONTEXT } from './execution-context.zod'; +import { EXPORT_ENTRY_POINTS, exportNamesOf, holdersOf } from '../../scripts/lib/export-origins-testkit'; describe('ExecutionContextSchema', () => { it('should accept empty context (all optional)', () => { @@ -199,3 +200,57 @@ describe('ExecutionContextSchema.preserveAudit — the published description (#6 expect(description).toMatch(/audit|updated_at/i); }); }); + + +// ─── [#17178] SEED_WRITE_EXECUTION_CONTEXT — one spelling of the seed posture ─ +// +// The seed-write context used to be a PRIVATE constant in three places +// (`SeedLoaderService.SEED_OPTIONS`, `AppPlugin`'s `SEED_WRITE_OPTIONS`, +// `@objectstack/verify`'s `SEED_CONTEXT`), and nothing held the three equal. +// They now all read this export, so the copies are gone by construction; what +// this block holds is the VALUE they read and the SURFACE it is read through. +// +// Why the value is pinned and not just documented: divergence here re-opens a +// boot-wedging defect. `skipTriggers` is what suppresses "on create" automation +// for seed rows — `isSystem` alone does NOT suppress dispatch — and a seed path +// that lost it once seeded with automation live while the main path had it +// suppressed, a self-trigger loop that wedged first boot (#3760). So "one flag +// looks redundant, drop it" is exactly the edit that must go red. +describe('[#17178] SEED_WRITE_EXECUTION_CONTEXT', () => { + it('is a valid ExecutionContext — the WHOLE value parses, not merely its key names', () => { + const parsed = ExecutionContextSchema.safeParse(SEED_WRITE_EXECUTION_CONTEXT); + expect(parsed.success, JSON.stringify('error' in parsed ? parsed.error : {})).toBe(true); + }); + + it('sets exactly the three seed flags and nothing else', () => { + expect(Object.keys(SEED_WRITE_EXECUTION_CONTEXT).sort()).toEqual([ + 'isSystem', + 'seedReplay', + 'skipTriggers', + ]); + }); + + it('sets `skipTriggers` — `isSystem` alone does NOT suppress trigger dispatch (#3760)', () => { + expect(SEED_WRITE_EXECUTION_CONTEXT.skipTriggers).toBe(true); + }); + + it('sets `seedReplay` — the state_machine exemption a mid-lifecycle seed row needs (#3433)', () => { + expect(SEED_WRITE_EXECUTION_CONTEXT.seedReplay).toBe(true); + }); + + it('sets `isSystem` — seeds target `sys_*` and declare their own tenancy columns', () => { + expect(SEED_WRITE_EXECUTION_CONTEXT.isSystem).toBe(true); + }); + + it('is reachable on exactly one public entry point — `./kernel`, the minimal widening', () => { + // Anti-vacuity first: the resolved surface must be the real one, or + // "exactly one holder" could pass by resolving nothing. + expect(EXPORT_ENTRY_POINTS, 'exports map must include ./kernel').toContain('./kernel'); + const kernelNames = exportNamesOf('./kernel'); + expect(kernelNames.length, './kernel must export a non-trivial surface').toBeGreaterThan(40); + expect(kernelNames, 'a neighbour that must stand').toContain('ExecutionContextSchema'); + + expect(kernelNames).toContain('SEED_WRITE_EXECUTION_CONTEXT'); + expect(holdersOf('SEED_WRITE_EXECUTION_CONTEXT')).toEqual(['./kernel']); + }); +}); diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index d604a39ee4..edc468ad40 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -433,3 +433,42 @@ export type ExecutionContext = z.input; /** Post-parse shape of {@link ExecutionContext} — defaults applied, transforms run (ADR-0122). */ export type ExecutionContextParsed = z.infer; + +/** + * The execution context EVERY seed write must use — the one spelling of the + * seed-write posture, so a seeder reads it instead of re-deriving it. + * + * Three flags, and the combination is load-bearing rather than cosmetic: + * + * - {@link skipTriggers} is what suppresses record-change AUTOMATION for seed + * rows. A seed is pre-existing END-STATE data, not a stream of user events, + * so firing on-create/on-update flows (notifications, escalations, + * assignments, approvals) for it is semantically wrong. **{@link isSystem} + * alone does NOT suppress dispatch** — only this flag does — and a seed + * path that omitted it once seeded with automation live while the main path + * had it suppressed, a self-trigger loop that wedged first boot (#3760). + * - {@link isSystem} elevates past permission/RLS enforcement and disables + * the SecurityPlugin's auto-injection of `organization_id` / `owner_id`: + * seeds declare those per record, or are intentionally global. + * - {@link seedReplay} (#3433) exempts the write from the object's + * `state_machine` rule, entry check and transitions both, because a seed is + * a snapshot of established facts. Every OTHER validation still runs. + * + * Because divergence between copies of this value re-opens a boot-wedging + * defect, it is declared HERE — beside the {@link ExecutionContext} contract + * whose keys it sets — rather than privately per seeder (#17178). It is the + * INNER context, deliberately: a seeder composes it into whatever options bag + * its call takes (`{ context: SEED_WRITE_EXECUTION_CONTEXT }`), on insert or on + * any other operation. ⛔ No options-bag or helper wrapper is exported around + * it — the bag belongs to the call site, the posture belongs here. + * + * Known readers: `SeedLoaderService.SEED_OPTIONS` + * (`@objectstack/metadata-protocol`), `AppPlugin`'s `SEED_WRITE_OPTIONS` + * (`@objectstack/runtime`, replaying a stack's declared `data[]`) and + * `@objectstack/verify`'s `seed(object, rows)` fixture writer. + */ +export const SEED_WRITE_EXECUTION_CONTEXT = { + isSystem: true, + skipTriggers: true, + seedReplay: true, +} as const satisfies ExecutionContext; diff --git a/packages/verify/src/handle.ts b/packages/verify/src/handle.ts index 9e1344859b..25fb58bbc9 100644 --- a/packages/verify/src/handle.ts +++ b/packages/verify/src/handle.ts @@ -47,7 +47,7 @@ // exists to retire, and would stay green when the engine changes. import { HttpDispatcher, type ObjectKernel, type HttpProtocolContext } from '@objectstack/runtime'; -import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { SEED_WRITE_EXECUTION_CONTEXT, type ExecutionContext } from '@objectstack/spec/kernel'; import type { ValidateDataResponse } from '@objectstack/spec/api'; import type { AutomationResult } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; @@ -238,12 +238,12 @@ export interface VerifyHandle { const API_PREFIX = '/api/v1'; /** - * The write context `AppPlugin` uses to replay a stack's declared `data[]` - * (`packages/runtime/src/app-plugin.ts`, `SEED_WRITE_OPTIONS`). Spelled here - * because the runtime keeps that constant module-private; the three flags are - * the engine's own documented `ExecutionContext` keys, not a dialect. + * The write context `AppPlugin` uses to replay a stack's declared `data[]` — + * read from the kernel's own {@link SEED_WRITE_EXECUTION_CONTEXT} rather than + * re-spelled here, so this fixture writer cannot drift from the seed posture + * the platform actually replays with (#17178). */ -const SEED_CONTEXT: ExecutionContext = { isSystem: true, skipTriggers: true, seedReplay: true } as ExecutionContext; +const SEED_CONTEXT: ExecutionContext = SEED_WRITE_EXECUTION_CONTEXT; const SYSTEM_CONTEXT: ExecutionContext = { isSystem: true } as ExecutionContext; function refusalFrom(status: number, body: unknown, fallback: string): VerifyRefusal {