Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .changeset/prune-workflow-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@workflow/ai': patch
'@workflow/cli': patch
'@workflow/core': patch
'@workflow/world': patch
'@workflow/world-local': patch
'@workflow/world-postgres': patch
'@workflow/world-testing': patch
'@workflow/world-vercel': patch
---

Keep schema-only World modules out of workflow VM bundles, upgrade Zod to 4.4, and make event response optionality explicit.
4 changes: 2 additions & 2 deletions docs/content/docs/v5/changelog/attributes-mvp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri
Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub):

- Validation rules — key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap)
- Reserved `$` namespace — rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`)
- Reserved `$` namespace — rejected by default and accepted by the contextual `validateAttributeChanges` check when `allowReservedAttributes: true` is passed
- `experimental_setAttributes({})` is a no-op (no dispatch, no events)
- `undefined` value normalizes to a `null`-valued change on the wire
- The `{ allowReservedAttributes: true }` opt-in is forwarded through the step bridge so the world receives the flag
Expand Down Expand Up @@ -367,7 +367,7 @@ For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventTy

### Validation rules are shared between SDK and world

Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file.
Context-free validation lives in the exported Zod schemas (`AttributeKeySchema`, `AttributeValueSchema`, `AttributeChangeSchema`, and `AttributeChangesSchema`). The schema-free `validateAttributeChanges` helper adds rules that depend on caller context, including the post-merge count and reserved `$` namespace. Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module remains the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved).

### Run row reconstruction had to thread `attributes` through

Expand Down
72 changes: 72 additions & 0 deletions packages/builders/src/workflow-bundle-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { BaseBuilder, type DiscoveredEntries } from './base-builder.js';
import type { StandaloneConfig } from './types.js';

class TestBuilder extends BaseBuilder {
async build(): Promise<void> {}

createWorkflowBundle(
inputFile: string,
outfile: string,
discoveredEntries: DiscoveredEntries
) {
return this.createWorkflowsBundle({
inputFiles: [inputFile],
outfile,
bundleFinalOutput: false,
discoveredEntries,
});
}
}

describe('workflow bundle boundary', () => {
const outputDirs: string[] = [];

afterEach(() => {
for (const outputDir of outputDirs) {
rmSync(outputDir, { recursive: true, force: true });
}
});

it('does not bundle world schemas into a workflow without schemas', async () => {
const repoRoot = resolve(import.meta.dirname, '../../..');
const outputDir = mkdtempSync(join(tmpdir(), 'workflow-pruning-'));
outputDirs.push(outputDir);
const inputFile = join(outputDir, 'minimal.ts');
writeFileSync(
inputFile,
`export async function minimal() { "use workflow"; return 1; }`
);

const config: StandaloneConfig = {
buildTarget: 'standalone',
workingDir: outputDir,
projectRoot: repoRoot,
moduleSpecifierRoot: repoRoot,
dirs: ['.'],
stepsBundlePath: join(outputDir, 'steps.js'),
workflowsBundlePath: join(outputDir, 'workflow.js'),
webhookBundlePath: join(outputDir, 'webhook.js'),
sourcemap: false,
};
const discoveredEntries: DiscoveredEntries = {
discoveredSteps: new Set(),
discoveredWorkflows: new Set([inputFile]),
discoveredSerdeFiles: new Set(),
};

const { interimBundleText } = await new TestBuilder(
config
).createWorkflowBundle(
inputFile,
config.workflowsBundlePath,
discoveredEntries
);

expect(interimBundleText).toBeDefined();
expect(interimBundleText).not.toContain('/node_modules/zod');
});
});
8 changes: 2 additions & 6 deletions packages/core/src/attribute-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@ import {
type AttributeChange,
AttributeValidationError,
validateAttributeChanges,
} from '@workflow/world';

interface AttributeChangeOptions {
allowReservedAttributes?: boolean;
}
} from '@workflow/world/attributes-validation';

export function normalizeAttributeChanges(
attrs: Record<string, string | undefined>,
options: AttributeChangeOptions = {}
options: { allowReservedAttributes?: boolean } = {}
): AttributeChange[] {
if (attrs === null || typeof attrs !== 'object' || Array.isArray(attrs)) {
throw new FatalError(
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/events-consumer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type Event, entityEventClass, envNumber } from '@workflow/world';
import type { Event } from '@workflow/world';
import { envNumber } from '@workflow/world/env-config';
import { entityEventClass } from '@workflow/world/event-metadata';
import { eventsLogger } from './logger.js';

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/flushable-stream.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { WorkflowRuntimeError } from '@workflow/errors';
import { type PromiseWithResolvers, withResolvers } from '@workflow/utils';
import { envNumber } from '@workflow/world';
import { envNumber } from '@workflow/world/env-config';
import { STREAM_DRAIN_SYMBOL } from './symbols.js';

/**
Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/runtime/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@ import {
WorkflowRunNotFoundError,
} from '@workflow/errors';
import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde';
import {
envNumber,
SPEC_VERSION_CURRENT,
type WorkflowRunStatus,
type World,
} from '@workflow/world';
import type { WorkflowRunStatus, World } from '@workflow/world';
import { envNumber } from '@workflow/world/env-config';
import { SPEC_VERSION_CURRENT } from '@workflow/world/spec-version';
import {
type DecryptionKey,
deriveRunPayloadKeys,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/serialization-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* Node imports, so this module remains safe to bundle for browsers.
*/

import { getEventDataRefFields } from '@workflow/world';
import { getEventDataRefFields } from '@workflow/world/event-metadata';
import { parse, unflatten } from 'devalue';
import {
decompress,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
WorkflowRuntimeError,
} from '@workflow/errors';
import { once } from '@workflow/utils';
import { envNumber } from '@workflow/world';
import { envNumber } from '@workflow/world/env-config';
import { parse, stringify, unflatten } from 'devalue';
import { monotonicFactory } from 'ulid';
import { importKey } from './encryption.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '@workflow/utils';
import { parseWorkflowName } from '@workflow/utils/parse-name';
import type { Event, WorkflowRun, WorldCapabilities } from '@workflow/world';
import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world';
import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world/spec-version';
import * as nanoid from 'nanoid';
import { monotonicFactory } from 'ulid';
import { EventConsumerResult, EventsConsumer } from './events-consumer.js';
Expand Down
9 changes: 6 additions & 3 deletions packages/world-vercel/src/events-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,9 +331,12 @@ const CreateEventV4PageSchema = z.union([
hasMore: z.boolean(),
}),
z.object({
events: z.undefined(),
cursor: z.undefined(),
hasMore: z.undefined(),
// Materialized writes omit page metadata entirely. Zod 4.4 correctly
// treats bare z.undefined() properties as required, so optionality must be
// explicit when the keys themselves may be absent.
events: z.undefined().optional(),
cursor: z.undefined().optional(),
hasMore: z.undefined().optional(),
}),
]);

Expand Down
16 changes: 16 additions & 0 deletions packages/world/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@
"main": "dist/index.js",
"exports": {
".": "./dist/index.js",
"./attributes-validation": {
"types": "./dist/attributes-validation.d.ts",
"default": "./dist/attributes-validation.js"
},
"./env-config": {
"types": "./dist/env-config.d.ts",
"default": "./dist/env-config.js"
},
"./event-metadata": {
"types": "./dist/event-metadata.d.ts",
"default": "./dist/event-metadata.js"
},
"./spec-version": {
"types": "./dist/spec-version.d.ts",
"default": "./dist/spec-version.js"
},
"./*": "./dist/*"
},
"publishConfig": {
Expand Down
127 changes: 127 additions & 0 deletions packages/world/src/attributes-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/** A single run-attribute change. `null` removes the key. */
export interface AttributeChange {
key: string;
value: string | null;
}

export const RESERVED_ATTRIBUTE_KEY_PREFIX = '$';
export const ROOT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}rootRunId`;
export const PARENT_RUN_ID_ATTRIBUTE = `${RESERVED_ATTRIBUTE_KEY_PREFIX}parentRunId`;
export const ATTRIBUTE_KEY_MAX_LENGTH = 256;
export const ATTRIBUTE_VALUE_MAX_BYTES = 256;
export const ATTRIBUTE_MAX_PER_RUN = 64;

const textEncoder = new TextEncoder();

/** A validation failure that callers can translate at their API boundary. */
export class AttributeValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'AttributeValidationError';
}
}

function assertValidAttributeKey(
key: unknown,
allowReservedAttributes: boolean
): asserts key is string {
if (typeof key !== 'string') {
throw new AttributeValidationError(
`Attribute key must be a string, got ${typeof key}`
);
}
if (key.length === 0) {
throw new AttributeValidationError('Attribute key must not be empty');
}
if (key.length > ATTRIBUTE_KEY_MAX_LENGTH) {
throw new AttributeValidationError(
`Attribute key length ${key.length} exceeds limit ${ATTRIBUTE_KEY_MAX_LENGTH}: ${JSON.stringify(key.slice(0, 32))}…`
);
}
if (
!allowReservedAttributes &&
key.startsWith(RESERVED_ATTRIBUTE_KEY_PREFIX)
) {
throw new AttributeValidationError(
`Attribute key ${JSON.stringify(key)} starts with reserved prefix "${RESERVED_ATTRIBUTE_KEY_PREFIX}" — that namespace is reserved for framework/library code. Set { allowReservedAttributes: true } only if your caller is framework-level.`
);
}
}

function assertValidAttributeValue(
value: unknown
): asserts value is string | null {
if (value !== null && typeof value !== 'string') {
throw new AttributeValidationError(
`Attribute value must be a string or null, got ${typeof value}`
);
}
if (value === null) return;

const bytes = textEncoder.encode(value).length;
if (bytes > ATTRIBUTE_VALUE_MAX_BYTES) {
throw new AttributeValidationError(
`Attribute value byte length ${bytes} exceeds limit ${ATTRIBUTE_VALUE_MAX_BYTES}`
);
}
}

function attributeCountDelta(
key: string,
value: string | null,
existingKeys: ReadonlySet<string> | undefined
): number {
if (value === null) return existingKeys?.has(key) ? -1 : 0;
return existingKeys === undefined || !existingKeys.has(key) ? 1 : 0;
}

export function validateAttributeChanges(
changes: AttributeChange[],
context: {
/** Existing keys make the post-merge count exact. */
existingKeys?: Iterable<string>;
/** Reserved `$` keys are only available to framework code. */
allowReservedAttributes?: boolean;
} = {}
): void {
const seenKeys = new Set<string>();
const existingKeys =
context.existingKeys === undefined
? undefined
: context.existingKeys instanceof Set
? context.existingKeys
: new Set(context.existingKeys);
let postMergeCount = existingKeys?.size ?? 0;
for (const change of changes) {
const { key, value } = change;
assertValidAttributeKey(key, context.allowReservedAttributes === true);
assertValidAttributeValue(value);
if (seenKeys.has(key)) {
throw new AttributeValidationError(
`Attribute key ${JSON.stringify(key)} appears more than once in the same batch`
);
}
seenKeys.add(key);
postMergeCount += attributeCountDelta(key, value, existingKeys);
}
if (postMergeCount > ATTRIBUTE_MAX_PER_RUN) {
throw new AttributeValidationError(
`Run attribute count would exceed limit ${ATTRIBUTE_MAX_PER_RUN} (post-merge ${postMergeCount})`
);
}
}

export function applyAttributeChanges(
existing: Record<string, string> | undefined,
changes: AttributeChange[]
): Record<string, string> {
const next = { ...(existing ?? {}) };
for (const { key, value } of changes) {
if (value === null) {
delete next[key];
} else {
next[key] = value;
}
}
return next;
}
Loading
Loading