diff --git a/.changeset/lucky-pandas-listen.md b/.changeset/lucky-pandas-listen.md
new file mode 100644
index 0000000000..0fee1366e7
--- /dev/null
+++ b/.changeset/lucky-pandas-listen.md
@@ -0,0 +1,5 @@
+---
+'@workflow/world-vercel': patch
+---
+
+Authenticate `deploymentId: "latest"` with the deployment's own OIDC identity instead of an ambient `VERCEL_TOKEN`, and scope the request to the configured team, fixing spurious 404s when resolving the latest deployment
diff --git a/.changeset/project-step-provenance.md b/.changeset/project-step-provenance.md
new file mode 100644
index 0000000000..960839e74e
--- /dev/null
+++ b/.changeset/project-step-provenance.md
@@ -0,0 +1,5 @@
+---
+'@workflow/web-shared': patch
+---
+
+Allow trace callers to add product-specific attributes to event-derived step spans.
diff --git a/docs/content/docs/v4/api-reference/workflow-api/start.mdx b/docs/content/docs/v4/api-reference/workflow-api/start.mdx
index 5d6a701ac8..96cf752b60 100644
--- a/docs/content/docs/v4/api-reference/workflow-api/start.mdx
+++ b/docs/content/docs/v4/api-reference/workflow-api/start.mdx
@@ -104,6 +104,18 @@ The `deploymentId` option is currently a Vercel-specific feature. Other Worlds m
In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development.
+
+Resolving `"latest"` is the one `start()` path that calls the Vercel API, so it
+needs an identity that can see the calling deployment. Inside a Vercel
+deployment the SDK authenticates with the deployment's own OIDC token, which
+carries the owning team, and this takes precedence over a `VERCEL_TOKEN` set in
+the function's environment. A `VERCEL_TOKEN` belongs to a *user* and carries no
+team, so authenticating with it scopes the lookup to that user's default team
+and fails with a 404 whenever that is not the team that owns the deployment.
+Outside a deployment (CLI, CI, the dashboard) `VERCEL_TOKEN` is still used;
+configure the World's `teamId` so the request is scoped explicitly.
+
+
When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility:
diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx
index 0fe6e443ec..c8c70e1f11 100644
--- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx
+++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx
@@ -108,6 +108,18 @@ The `deploymentId` option is currently a Vercel-specific feature. Other Worlds m
In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development.
+
+Resolving `"latest"` is the one `start()` path that calls the Vercel API, so it
+needs an identity that can see the calling deployment. Inside a Vercel
+deployment the SDK authenticates with the deployment's own OIDC token, which
+carries the owning team, and this takes precedence over a `VERCEL_TOKEN` set in
+the function's environment. A `VERCEL_TOKEN` belongs to a *user* and carries no
+team, so authenticating with it scopes the lookup to that user's default team
+and fails with a 404 whenever that is not the team that owns the deployment.
+Outside a deployment (CLI, CI, the dashboard) `VERCEL_TOKEN` is still used;
+configure the World's `teamId` so the request is scoped explicitly.
+
+
When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility:
diff --git a/packages/web-shared/src/components/trace-viewer.tsx b/packages/web-shared/src/components/trace-viewer.tsx
index b64da197ec..612d3ec046 100644
--- a/packages/web-shared/src/components/trace-viewer.tsx
+++ b/packages/web-shared/src/components/trace-viewer.tsx
@@ -8,6 +8,7 @@ import {
} from './sidebar/sidebar-data-context';
import { TraceViewerSkeleton } from './trace-viewer/components/trace-viewer-skeleton';
import { TraceViewer as TraceViewerComponent } from './trace-viewer/trace-viewer';
+import type { GetStepAttributes } from './workflow-traces/trace-span-construction';
const TraceViewer = ({
run,
@@ -17,6 +18,7 @@ const TraceViewer = ({
hasMore,
isLoadingMore,
loading = false,
+ getStepAttributes,
}: {
run: WorkflowRun;
events: Event[];
@@ -25,6 +27,8 @@ const TraceViewer = ({
hasMore?: boolean;
isLoadingMore?: boolean;
loading?: boolean;
+ /** Adds product-specific attributes to event-derived step span data. */
+ getStepAttributes?: GetStepAttributes;
}) => {
const trace: TraceWithMeta | undefined = useMemo(() => {
if (!run?.runId) {
@@ -35,9 +39,10 @@ const TraceViewer = ({
// repeats with the whole log in hand.
return buildTrace(run, events, new Date(), {
isCompleteHistory: !hasMore,
+ getStepAttributes,
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
- }, [run, events, hasMore]);
+ }, [run, events, hasMore, getStepAttributes]);
// The sidebar shows one entity's slice of the log, so it takes the trace's
// answer rather than recomputing one from the slice.
diff --git a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts
index c71448712e..876477df2c 100644
--- a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts
+++ b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts
@@ -150,6 +150,10 @@ export function waitToSpan(
};
}
+export type GetStepAttributes = (
+ events: Event[]
+) => Record | undefined;
+
export const stepEventsToStepEntity = (
events: Event[]
): {
@@ -231,7 +235,11 @@ export const stepEventsToStepEntity = (
/**
* Converts step events to an OpenTelemetry Span
*/
-export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
+export function stepToSpan(
+ stepEvents: Event[],
+ maxEndTime: Date,
+ getStepAttributes?: GetStepAttributes
+): Span | null {
const step = stepEventsToStepEntity(stepEvents);
if (!step) {
return null;
@@ -242,7 +250,11 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
const attributes = {
resource: 'step' as const,
- data: step,
+ data: {
+ ...getStepAttributes?.(stepEvents),
+ // Canonical event-derived fields cannot be overridden by extensions.
+ ...step,
+ },
};
const resource = 'step';
diff --git a/packages/web-shared/src/lib/trace-builder.test.ts b/packages/web-shared/src/lib/trace-builder.test.ts
index 8de5282d24..00dae7c556 100644
--- a/packages/web-shared/src/lib/trace-builder.test.ts
+++ b/packages/web-shared/src/lib/trace-builder.test.ts
@@ -9,7 +9,11 @@ let nextId = 0;
function event(
eventType: EventType,
- options: { correlationId?: string; at: number }
+ options: {
+ correlationId?: string;
+ at: number;
+ externalAttemptId?: string;
+ }
): Event {
nextId += 1;
return {
@@ -20,6 +24,7 @@ function event(
createdAt: new Date(BASE_TIME + options.at * 1000),
occurredAt: new Date(BASE_TIME + options.at * 1000),
eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {},
+ externalAttemptId: options.externalAttemptId,
} as unknown as Event;
}
@@ -31,6 +36,42 @@ const run = {
} as unknown as WorkflowRun;
describe('buildTrace', () => {
+ it('adds caller-derived attributes to step span data', () => {
+ const events = [
+ event('run_created', { at: 0 }),
+ event('run_started', { at: 0 }),
+ event('step_created', { correlationId: 'step_a', at: 1 }),
+ event('step_started', {
+ correlationId: 'step_a',
+ at: 2,
+ externalAttemptId: 'attempt_first',
+ }),
+ event('step_retrying', { correlationId: 'step_a', at: 3 }),
+ event('step_started', {
+ correlationId: 'step_a',
+ at: 4,
+ externalAttemptId: 'attempt_latest',
+ }),
+ ];
+
+ const trace = buildTrace(run, events, new Date(BASE_TIME + 5000), {
+ getStepAttributes(stepEvents) {
+ const latestStart = stepEvents
+ .slice()
+ .reverse()
+ .find((candidate) => candidate.eventType === 'step_started') as
+ | (Event & { externalAttemptId?: string })
+ | undefined;
+ return { externalAttemptId: latestStart?.externalAttemptId };
+ },
+ });
+ const stepSpan = trace.spans.find((span) => span.resource === 'step');
+
+ expect(stepSpan?.attributes.data).toMatchObject({
+ externalAttemptId: 'attempt_latest',
+ });
+ });
+
it('ends a step span on the terminal event the run acted on', () => {
const events = [
event('run_created', { at: 0 }),
diff --git a/packages/web-shared/src/lib/trace-builder.ts b/packages/web-shared/src/lib/trace-builder.ts
index ec671ebc66..f4015d40ee 100644
--- a/packages/web-shared/src/lib/trace-builder.ts
+++ b/packages/web-shared/src/lib/trace-builder.ts
@@ -14,6 +14,7 @@ import {
type WorkflowRun,
} from '@workflow/world';
import {
+ type GetStepAttributes,
getEventTimestamp,
hookToSpan,
runToSpan,
@@ -137,7 +138,8 @@ function buildSpans(
run: WorkflowRun,
groupedEvents: GroupedEvents,
now: Date,
- latestKnownTime: Date
+ latestKnownTime: Date,
+ getStepAttributes?: GetStepAttributes
) {
// Active child spans cap at latestKnownTime so they don't extend into
// unknown territory. Even when the run is completed, we may not have loaded
@@ -146,7 +148,7 @@ function buildSpans(
const runMaxEnd = run.completedAt ?? now;
const stepSpans = Array.from(groupedEvents.eventsByStepId.values())
- .map((events) => stepToSpan(events, childMaxEnd))
+ .map((events) => stepToSpan(events, childMaxEnd, getStepAttributes))
.filter((span): span is Span => span !== null);
const hookSpans = Array.from(groupedEvents.hookEvents.values())
@@ -208,7 +210,13 @@ export function buildTrace(
* from the only copy the caller was given, and dropping the wrong one moves
* a span. See {@link findDuplicateEventIds}.
*/
- { isCompleteHistory = false }: { isCompleteHistory?: boolean } = {}
+ {
+ isCompleteHistory = false,
+ getStepAttributes,
+ }: {
+ isCompleteHistory?: boolean;
+ getStepAttributes?: GetStepAttributes;
+ } = {}
): TraceWithMeta {
// Span geometry comes from what the run acted on. A repeat of a class the
// log already records is read past by every replay, and letting one through
@@ -232,7 +240,8 @@ export function buildTrace(
run,
groupedEvents,
now,
- latestKnownTime
+ latestKnownTime,
+ getStepAttributes
);
const sortedCascadingSpans = cascadeSpans(runSpan, spans);
diff --git a/packages/world-vercel/src/resolve-latest-deployment.test.ts b/packages/world-vercel/src/resolve-latest-deployment.test.ts
index e89ac9608a..f9f4081855 100644
--- a/packages/world-vercel/src/resolve-latest-deployment.test.ts
+++ b/packages/world-vercel/src/resolve-latest-deployment.test.ts
@@ -154,6 +154,129 @@ describe('createResolveLatestDeploymentId', () => {
expect(headers.get('Authorization')).toBe('Bearer oidc-token-456');
});
+ it('prefers the OIDC token over VERCEL_TOKEN inside the Vercel runtime', async () => {
+ // The shape that broke production: a deployed function whose environment
+ // also carries a VERCEL_TOKEN for unrelated tooling. The token is a user
+ // credential and carries no team, so authenticating with it scopes the
+ // lookup to that user's default team and 404s.
+ process.env.VERCEL = '1';
+ process.env.VERCEL_TOKEN = 'user-token-wrong-team';
+
+ const { getVercelOidcToken } = await import('@vercel/oidc');
+ vi.mocked(getVercelOidcToken).mockResolvedValueOnce('oidc-token-789');
+
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ id: 'dpl_latest_from_oidc' }), {
+ status: 200,
+ })
+ );
+
+ const result = await createResolveLatestDeploymentId({})();
+
+ expect(result).toBe('dpl_latest_from_oidc');
+ const headers = mockFetch.mock.calls[0][1].headers as Headers;
+ expect(headers.get('Authorization')).toBe('Bearer oidc-token-789');
+ });
+
+ it('keeps using VERCEL_TOKEN outside the Vercel runtime', async () => {
+ // CLI and CI callers export VERCEL_TOKEN deliberately and have no
+ // deployment identity to prefer, so the original order stands there.
+ delete process.env.VERCEL;
+ process.env.VERCEL_TOKEN = 'env-token-123';
+
+ const { getVercelOidcToken } = await import('@vercel/oidc');
+ vi.mocked(getVercelOidcToken).mockResolvedValueOnce('oidc-token-789');
+
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ id: 'dpl_from_env' }), { status: 200 })
+ );
+
+ await createResolveLatestDeploymentId({})();
+
+ const headers = mockFetch.mock.calls[0][1].headers as Headers;
+ expect(headers.get('Authorization')).toBe('Bearer env-token-123');
+ });
+
+ it('still prefers an explicit config token inside the Vercel runtime', async () => {
+ process.env.VERCEL = '1';
+ process.env.VERCEL_TOKEN = 'env-token-123';
+
+ // Queueing an OIDC value here would leak into the next test, since an
+ // explicit config token short-circuits before OIDC is ever consulted.
+ // Assert that directly instead.
+ const { getVercelOidcToken } = await import('@vercel/oidc');
+ vi.mocked(getVercelOidcToken).mockClear();
+
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ id: 'dpl_from_config' }), { status: 200 })
+ );
+
+ await createResolveLatestDeploymentId({ token: 'config-token' })();
+
+ const headers = mockFetch.mock.calls[0][1].headers as Headers;
+ expect(headers.get('Authorization')).toBe('Bearer config-token');
+ expect(getVercelOidcToken).not.toHaveBeenCalled();
+ });
+
+ it('falls back to VERCEL_TOKEN inside the runtime when OIDC is unavailable', async () => {
+ process.env.VERCEL = '1';
+ process.env.VERCEL_TOKEN = 'env-token-123';
+
+ const { getVercelOidcToken } = await import('@vercel/oidc');
+ // mockReset clears any queue a prior test left behind; the rejection is
+ // scoped to this call so it does not leak into later tests.
+ vi.mocked(getVercelOidcToken).mockReset();
+ vi.mocked(getVercelOidcToken).mockRejectedValueOnce(new Error('no OIDC'));
+
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ id: 'dpl_from_env' }), { status: 200 })
+ );
+
+ await createResolveLatestDeploymentId({})();
+
+ const headers = mockFetch.mock.calls[0][1].headers as Headers;
+ expect(headers.get('Authorization')).toBe('Bearer env-token-123');
+ });
+
+ it('scopes the request to the configured team', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ id: 'dpl_latest' }), { status: 200 })
+ );
+
+ await createResolveLatestDeploymentId({
+ token: 'test-token',
+ projectConfig: { projectId: 'prj_123', teamId: 'team_abc' },
+ })();
+
+ const url = new URL(mockFetch.mock.calls[0][0] as string);
+ expect(url.pathname).toBe(
+ '/v1/workflow/resolve-latest-deployment/dpl_current_abc123'
+ );
+ expect(url.searchParams.get('teamId')).toBe('team_abc');
+ });
+
+ it('omits the team parameter when no team is configured', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify({ id: 'dpl_latest' }), { status: 200 })
+ );
+
+ await createResolveLatestDeploymentId({ token: 'test-token' })();
+
+ expect(mockFetch.mock.calls[0][0]).toBe(
+ 'https://api.vercel.com/v1/workflow/resolve-latest-deployment/dpl_current_abc123'
+ );
+ });
+
+ it('explains the identity cause on a 404', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('Deployment not found.', { status: 404 })
+ );
+
+ await expect(
+ createResolveLatestDeploymentId({ token: 'test-token' })()
+ ).rejects.toThrow(/not visible to the identity/);
+ });
+
it('should throw on non-ok HTTP response', async () => {
mockFetch.mockResolvedValueOnce(new Response('Not found', { status: 404 }));
diff --git a/packages/world-vercel/src/resolve-latest-deployment.ts b/packages/world-vercel/src/resolve-latest-deployment.ts
index 291a1afcd9..0d32e99e09 100644
--- a/packages/world-vercel/src/resolve-latest-deployment.ts
+++ b/packages/world-vercel/src/resolve-latest-deployment.ts
@@ -6,6 +6,7 @@
* deployments) as the provided current deployment.
*/
+import { getVercelOidcToken } from '@vercel/oidc';
import * as z from 'zod';
import { missingDeploymentIdMessage } from './deployment-id.js';
import { getDispatcher } from './http-client.js';
@@ -16,6 +17,41 @@ const ResolveLatestDeploymentResponseSchema = z.object({
id: z.string(),
});
+/**
+ * Resolve the credential this call should authenticate with.
+ *
+ * This endpoint is scoped to a *team* on the API side: it reads the source
+ * deployment out of a team-partitioned store, so the identity we present
+ * decides which team's deployments are visible. That makes the credential
+ * choice a correctness concern here, not just an auth detail, and it is why
+ * this path does not simply use `resolveVercelApiToken`.
+ *
+ * Inside the Vercel runtime the OIDC token is preferred over `VERCEL_TOKEN`,
+ * reversing that helper's order. The OIDC token carries the deployment's own
+ * `owner_id`/`project_id` claims, so the API resolves exactly the team that
+ * owns the deployment we are asking about. A `VERCEL_TOKEN` in the function's
+ * environment is a *user's* credential: it carries no team, so the API falls
+ * back to that user's default team, and the lookup then misses on every team
+ * but that one and returns 404. An ambient token set for unrelated tooling
+ * must not displace the deployment's own identity.
+ *
+ * Outside the runtime (CLI, CI, the dashboard) the original order is kept.
+ * There is no deployment identity to prefer, `VERCEL_TOKEN` is usually set
+ * deliberately, and those callers pass an explicit `teamId` instead.
+ */
+async function resolveDeploymentIdentityToken(
+ config?: APIConfig
+): Promise {
+ if (config?.token) return config.token;
+
+ if (process.env.VERCEL === '1') {
+ const oidcToken = await getVercelOidcToken().catch(() => null);
+ if (oidcToken) return oidcToken;
+ }
+
+ return resolveVercelApiToken(config);
+}
+
/**
* Create the `resolveLatestDeploymentId` implementation for a Vercel World.
*
@@ -38,14 +74,21 @@ export function createResolveLatestDeploymentId(
);
}
- const token = await resolveVercelApiToken(config);
+ const token = await resolveDeploymentIdentityToken(config);
if (!token) {
throw new Error(
'Cannot resolve latest deployment: no OIDC token or VERCEL_TOKEN available'
);
}
- const url = `https://api.vercel.com/v1/workflow/resolve-latest-deployment/${encodeURIComponent(currentDeploymentId)}`;
+ // Scope the request to the owning team when the caller knows it, the same
+ // way the sibling run-key request does. Without either this parameter or
+ // an OIDC token's `owner_id` claim, the API scopes the lookup to the
+ // token owner's *default* team and 404s when that is not the team that
+ // owns the deployment.
+ const teamId = config?.projectConfig?.teamId;
+ const query = teamId ? `?${new URLSearchParams({ teamId })}` : '';
+ const url = `https://api.vercel.com/v1/workflow/resolve-latest-deployment/${encodeURIComponent(currentDeploymentId)}${query}`;
// 429/5xx retries are handled by the shared RetryAgent from getDispatcher().
// instrumentedFetch adds the OTEL client span + DEBUG logging the v3/v4
@@ -66,8 +109,19 @@ export function createResolveLatestDeploymentId(
} catch {
body = '';
}
+ // A 404 here reads as "the deployment does not exist", but the far
+ // more common cause is that the request resolved to a team that does
+ // not own it, so the team-partitioned lookup missed. Name that, or
+ // the next reader debugs deployment state instead of identity.
+ const hint =
+ res.status === 404
+ ? '. The deployment exists but was not visible to the identity this' +
+ ' request authenticated as, which means the request resolved to a' +
+ " different team. Set the World's `projectConfig.teamId` to scope" +
+ ' it explicitly.'
+ : '';
return new Error(
- `Failed to resolve latest deployment for ${currentDeploymentId}: HTTP ${res.status} ${res.statusText}${body ? ` — ${body}` : ''}`
+ `Failed to resolve latest deployment for ${currentDeploymentId}: HTTP ${res.status} ${res.statusText}${body ? ` — ${body}` : ''}${hint}`
);
},
});