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/17178-seed-write-execution-context-export.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 13 additions & 24 deletions packages/metadata-protocol/src/seed-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 14 additions & 9 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@
"SBOMEntrySchema (const)",
"SBOMParsed (type)",
"SBOMSchema (const)",
"SEED_WRITE_EXECUTION_CONTEXT (const)",
"SandboxConfig (type)",
"SandboxConfigParsed (type)",
"SandboxConfigSchema (const)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/kernel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
57 changes: 56 additions & 1 deletion packages/spec/src/kernel/execution-context.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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']);
});
});
39 changes: 39 additions & 0 deletions packages/spec/src/kernel/execution-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,42 @@ export type ExecutionContext = z.input<typeof ExecutionContextSchema>;
/** Post-parse shape of {@link ExecutionContext} — defaults applied, transforms run (ADR-0122). */
export type ExecutionContextParsed = z.infer<typeof ExecutionContextSchema>;


/**
* 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;
12 changes: 6 additions & 6 deletions packages/verify/src/handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Loading