diff --git a/.changeset/deprecate-experimental-set-attributes.md b/.changeset/deprecate-experimental-set-attributes.md new file mode 100644 index 0000000000..6c11ab4c9a --- /dev/null +++ b/.changeset/deprecate-experimental-set-attributes.md @@ -0,0 +1,5 @@ +--- +"@workflow/world": patch +--- + +Deprecate the optional `runs.experimentalSetAttributes` World method and `ExperimentalSetAttributesResult`. The SDK no longer calls it; `setAttributes()` writes `attr_set` events through `events.create`. diff --git a/.changeset/remove-set-attributes-bridge.md b/.changeset/remove-set-attributes-bridge.md new file mode 100644 index 0000000000..e44827615f --- /dev/null +++ b/.changeset/remove-set-attributes-bridge.md @@ -0,0 +1,5 @@ +--- +"workflow": patch +--- + +Remove the unused `__builtin_set_attributes` step, the attributes MVP bridge that wrote through `runs.experimentalSetAttributes`. diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index 728105f375..1bd39339ba 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -119,10 +119,6 @@ interface Storage { runs: { get(id: string, params?: GetWorkflowRunParams): Promise; list(params?: ListWorkflowRunsParams): Promise>; - // Optional: backs setAttributes(). Omitting it means run attributes are - // unsupported on this World. The SDK helper no-ops with a warning. - experimentalSetAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise; - // Optional: long poll for a terminal status (see below) waitForTerminalStatus?(id: string, params?: WaitForTerminalRunStatusParams): Promise; }; @@ -170,6 +166,8 @@ interface Storage { **Run creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`. +**Run attributes:** there is no Storage method for attributes. `setAttributes()` writes an `attr_set` event, and `start(..., { attributes })` passes initial attributes on `run_created`. Validate them with `validateAttributeChanges()` from `@workflow/world` and merge them onto the run's `attributes` in the same atomic update as the event: a string value sets a key, `null` removes it. Reject `attr_set` on a run in a terminal state. + **Event data resolution:** `events.list()` and `events.create()` accept `resolveData: 'skip-step-inputs'`, and the runtime replays with it. The World may leave `input` out of `step_created` and `step_started` events, and returns everything else as for `'all'`. Treat any value other than `'none'` as `'all'`, because a World that tests `resolveData === 'all'` strips step results and breaks every replay. Map the value with `entityResolveData()` from `@workflow/world` before passing it to an entity read. **Run listing:** `runs.list({ status })` accepts a single status or an array. With an array, return runs whose status matches any listed value, and return no runs for an empty array (the same semantics as SQL `IN ()`). Treat an omitted `status` as no filter. diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx index 403600cba2..688d655947 100644 --- a/docs/content/worlds/v5/upgrading-to-v5.mdx +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -140,7 +140,6 @@ None of this is required. Each entry is a hook the runtime uses if your World pr | `events.createBatch` | Appends an ordered list of events in one durable write, with a per-event outcome for each. Implementing the method *is* the declaration: the runtime folds a suspension's `step_created` / `wait_created` writes into batches only when it exists, and otherwise takes the single-event path unchanged. Implement it with real atomicity per attempt, so a lost race leaves nothing behind, or leave it out. | | `runs.waitForTerminalStatus` | Long-polls until a run reaches a terminal status. `await run.returnValue` uses it when present, instead of polling on an interval. | | `analytics` | A metadata-only read namespace for observability surfaces. Payload-bearing reads stay on `runs`, `steps`, `events`, and `hooks`. | -| `runs.experimentalSetAttributes` | Backs `setAttributes()` from application code. Without it, run attributes are unavailable. | | `runs.cancelMany` | Bulk cancellation: up to 500 unique run IDs per request (`BULK_CANCEL_MAX_RUN_IDS`), an optional `cancelReason` of at most 512 characters, and a per-run outcome for every ID. Without it, the runtime falls back to bounded-concurrency individual cancels. | | `getRuntimeDeadline()` | The absolute time the current invocation will be terminated. The runtime derives its inline replay budget from this, so a host with a long function timeout gets more work per invocation. Without it, the budget is a flat two minutes. See [`WORKFLOW_V2_TIMEOUT_MS`](/docs/configuration/runtime-tuning#workflow_v2_timeout_ms). | | `getEnvironment()` | The environment your World's writes are attributed to. Must be synchronous, side-effect free, and match what the backend will actually apply. A wrong answer is worse than `undefined`, because callers use it to detect cross-environment mismatches. Worlds with a single tenant should omit it. | diff --git a/packages/core/src/attributes-mvp.md b/packages/core/src/attributes-mvp.md index 13136cd960..c448d3ae35 100644 --- a/packages/core/src/attributes-mvp.md +++ b/packages/core/src/attributes-mvp.md @@ -14,6 +14,12 @@ The v4 implementation now completes the planned dispatch migration while preserv - The World spec is bumped to v4; attributes are supported for v4 runs rather than being backported into older run specifications. - The direct server attributes endpoint remains available only as a deprecated compatibility path for clients that shipped with the MVP. +## Retiring the MVP write path + +The MVP write path shipped in `workflow` 5.0.0-beta.8 through 5.0.0-beta.14 (step-body calls from beta.9). From beta.15 on, the SDK writes attributes only as `attr_set` events, so nothing in the current SDK calls `runs.experimentalSetAttributes`. The `__builtin_set_attributes` step bridge has been removed: no current workflow body dispatches it, and a run whose log still records it (an MVP-era run, spec version 3) cannot be replayed by a v5 runtime at all, because its event IDs are not slot-numbered. + +`runs.experimentalSetAttributes` and `ExperimentalSetAttributesResult` stay on the World interface, optional and `@deprecated`, so existing implementations keep compiling. The first-party Worlds keep implementing the method, and `world-vercel` keeps posting to `POST /v2/runs/:runId/attributes`, because a newer World package can still be installed next to one of those betas: `world-postgres` has no peer dependency on `workflow` or `@workflow/core`, and any World package can be selected by name through `WORKFLOW_TARGET_WORLD`. Drop the implementations once those betas no longer need supporting. + The remainder of this document documents the original MVP motivation and implementation. ## What MVP supports diff --git a/packages/core/src/set-attributes.test.ts b/packages/core/src/set-attributes.test.ts index cd1e33726a..c0ef823055 100644 --- a/packages/core/src/set-attributes.test.ts +++ b/packages/core/src/set-attributes.test.ts @@ -87,6 +87,22 @@ describe('setAttributes (host-side)', () => { ); }); + it('writes an attr_set event, not the deprecated runs.experimentalSetAttributes', async () => { + const create = vi.fn().mockResolvedValue({}); + const experimentalSetAttributes = vi.fn(); + globals[WORLD_CACHE] = { + specVersion: SPEC_VERSION_CURRENT, + name: 'test-world', + events: { create }, + runs: { experimentalSetAttributes }, + }; + + await runInStepContext(() => setAttributes({ phase: 'ready' })); + + expect(create).toHaveBeenCalledTimes(1); + expect(experimentalSetAttributes).not.toHaveBeenCalled(); + }); + it('forwards allowReservedAttributes for step-side reserved namespace writes', async () => { const create = vi.fn().mockResolvedValue({}); globals[WORLD_CACHE] = { diff --git a/packages/workflow/src/internal/builtins.test.ts b/packages/workflow/src/internal/builtins.test.ts deleted file mode 100644 index ee0f5fe4c2..0000000000 --- a/packages/workflow/src/internal/builtins.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { __builtin_set_attributes } from './builtins.js'; - -const STEP_CONTEXT_STORAGE = Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE'); -const WORLD_CACHE = Symbol.for('@workflow/world//cache'); -const UNSUPPORTED_WORLD_WARNED = Symbol.for( - '@workflow/setAttributes//unsupportedWorldWarned' -); - -const globals = globalThis as Record; - -function setStepAttempt(attempt: number) { - globals[STEP_CONTEXT_STORAGE] = { - getStore: () => ({ - stepMetadata: { attempt }, - workflowMetadata: { workflowRunId: 'run_123' }, - }), - }; -} - -describe('__builtin_set_attributes', () => { - let originalContextStorage: unknown; - let originalWorld: unknown; - let originalUnsupportedWorldWarned: unknown; - - beforeEach(() => { - originalContextStorage = globals[STEP_CONTEXT_STORAGE]; - originalWorld = globals[WORLD_CACHE]; - originalUnsupportedWorldWarned = globals[UNSUPPORTED_WORLD_WARNED]; - }); - - afterEach(() => { - vi.restoreAllMocks(); - - if (originalContextStorage === undefined) { - delete globals[STEP_CONTEXT_STORAGE]; - } else { - globals[STEP_CONTEXT_STORAGE] = originalContextStorage; - } - - if (originalWorld === undefined) { - delete globals[WORLD_CACHE]; - } else { - globals[WORLD_CACHE] = originalWorld; - } - - if (originalUnsupportedWorldWarned === undefined) { - delete globals[UNSUPPORTED_WORLD_WARNED]; - } else { - globals[UNSUPPORTED_WORLD_WARNED] = originalUnsupportedWorldWarned; - } - }); - - it('rethrows attribute write failures before the third attempt', async () => { - const error = new Error('world unavailable'); - const experimentalSetAttributes = vi.fn().mockRejectedValue(error); - globals[WORLD_CACHE] = { - runs: { experimentalSetAttributes }, - }; - - for (const attempt of [1, 2]) { - setStepAttempt(attempt); - - await expect( - __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { - allowReservedAttributes: true, - }) - ).rejects.toBe(error); - } - - expect(experimentalSetAttributes).toHaveBeenCalledTimes(2); - }); - - it('logs and completes after the third failed attempt', async () => { - const experimentalSetAttributes = vi - .fn() - .mockRejectedValue(new Error('world unavailable')); - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - globals[WORLD_CACHE] = { - runs: { experimentalSetAttributes }, - }; - setStepAttempt(3); - - await expect( - __builtin_set_attributes([{ key: '$tag.kind', value: 'agent' }], { - allowReservedAttributes: true, - }) - ).resolves.toBeUndefined(); - - expect(experimentalSetAttributes).toHaveBeenCalledWith( - 'run_123', - [{ key: '$tag.kind', value: 'agent' }], - { allowReservedAttributes: true } - ); - expect(consoleError).toHaveBeenCalledTimes(1); - expect(consoleError.mock.calls[0]?.[0]).toContain( - 'failed to post tags after 3 attempts' - ); - expect( - ( - __builtin_set_attributes as typeof __builtin_set_attributes & { - maxRetries: number; - } - ).maxRetries - ).toBe(2); - }); -}); diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 7b1d57015e..101230ef84 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -20,111 +20,3 @@ export async function __builtin_response_text(this: Request | Response) { 'use step'; return this.text(); } - -/** - * Process-wide dedupe for the unsupported-world warning so high-volume - * callers don't flood logs. - */ -const UNSUPPORTED_WORLD_WARNED = Symbol.for( - '@workflow/setAttributes//unsupportedWorldWarned' -); - -const INTERNAL_ATTRIBUTES_MAX_ATTEMPTS = 3; - -function formatUnknownError(error: unknown) { - if (error instanceof Error) { - return error.stack ?? `${error.name}: ${error.message}`; - } - return String(error); -} - -/** - * Step bridge for workflow-body `setAttributes` calls. The VM-side - * helper validates input and dispatches here via `useStep`. This step - * runs in normal Node context with full world access. - * - * The dispatch reads the world and current run id directly from - * `globalThis` symbols populated by the workflow/step runtime. This - * intentionally avoids importing `@workflow/core` so the Next.js - * deferred-entries discoverer can't walk a chain into world adapters - * and `@vercel/queue` from this step file. - */ -export async function __builtin_set_attributes( - changes: Array<{ key: string; value: string | null }>, - options?: { allowReservedAttributes?: boolean } -) { - 'use step'; - if (changes.length === 0) return; - const g = globalThis as Record; - - const contextStorage = g[Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')] as - | { - getStore: () => - | { - stepMetadata?: { attempt?: number }; - workflowMetadata?: { workflowRunId?: string }; - } - | undefined; - } - | undefined; - const store = contextStorage?.getStore?.(); - const attempt = - typeof store?.stepMetadata?.attempt === 'number' - ? store.stepMetadata.attempt - : INTERNAL_ATTRIBUTES_MAX_ATTEMPTS; - - const world = g[Symbol.for('@workflow/world//cache')] as - | { - name?: string; - runs?: { - experimentalSetAttributes?: ( - runId: string, - changes: Array<{ key: string; value: string | null }>, - options?: { allowReservedAttributes?: boolean } - ) => Promise; - }; - } - | undefined; - if (typeof world?.runs?.experimentalSetAttributes !== 'function') { - // World adapter doesn't implement attributes yet, so no-op the call, - // but emit one process-wide warning so users know their writes are - // being dropped. The VM-side validation already ran so the input - // is well-formed. - if (!g[UNSUPPORTED_WORLD_WARNED]) { - g[UNSUPPORTED_WORLD_WARNED] = true; - const worldName = world?.name ? ` (${world.name})` : ''; - console.warn( - `[workflow] setAttributes: the current world implementation${worldName} does not implement experimentalSetAttributes; this call (and any subsequent setAttributes calls in this process) is a no-op. Attributes will become available once the world adapter adds support.` - ); - } - return; - } - - try { - const runId = store?.workflowMetadata?.workflowRunId; - if (!runId) { - throw new Error( - '__builtin_set_attributes: no workflow run id available in step context' - ); - } - - await world.runs.experimentalSetAttributes(runId, changes, options); - } catch (error) { - if (attempt < INTERNAL_ATTRIBUTES_MAX_ATTEMPTS) { - throw error; - } - - // Failing to post tags should not fail a run during the experimental phase. - // After three attempts, log and let the internal step complete so the - // runtime does not convert retry exhaustion into a FatalError. - console.error( - `[workflow] setAttributes: failed to post tags after ${INTERNAL_ATTRIBUTES_MAX_ATTEMPTS} attempts; dropping the internal attribute write. ${formatUnknownError(error)}` - ); - } -} - -( - __builtin_set_attributes as typeof __builtin_set_attributes & { - maxRetries: number; - } -).maxRetries = INTERNAL_ATTRIBUTES_MAX_ATTEMPTS - 1; diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index 177e870492..a592a544a8 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -53,7 +53,13 @@ export const AttributeChangesSchema = z } }); -/** The post-merge attribute snapshot returned by a World. */ +/** + * The post-merge attribute snapshot returned by the deprecated + * `runs.experimentalSetAttributes`. + * + * @deprecated The SDK no longer calls `runs.experimentalSetAttributes`; run + * attributes are written as `attr_set` events. See that method on `Storage`. + */ export interface ExperimentalSetAttributesResult { attributes: Record; } diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 9d5427a275..fe9ebadfad 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -293,30 +293,20 @@ export interface Storage { ): Promise>; /** - * Apply a batch of attribute changes to a run. Merge semantics: - * - `value: string` upserts the key - * - `value: null` removes the key - * - keys not listed in `changes` are untouched + * Apply a batch of attribute changes directly to a run's materialized + * `attributes`, outside the event log, and return the post-merge + * snapshot. * - * Returns the post-merge attribute snapshot on the run. + * @deprecated The current SDK never calls this method. `setAttributes()` + * writes `attr_set` events through `events.create`, and + * `start(..., { attributes })` seeds attributes on `run_created`; every + * World already handles both. New Worlds should not implement it. * - * Pass `options.allowReservedAttributes: true` to permit keys - * starting with the reserved `$` prefix. Default behavior rejects - * those keys so user code can't accidentally collide with - * framework / tooling namespaces; framework callers that own a - * sub-namespace flip this on. - * - * OPTIONAL. World implementations may omit this method; the SDK - * helper (`setAttributes` in `@workflow/core`) feature-detects its - * absence and no-ops with a one-time warning so third-party / - * community worlds keep working without adopting the experimental - * API. - * - * EXPERIMENTAL: this method exists as a stopgap until the - * `attr_set` event type lands in a future spec version. When that - * happens, `setAttributes` will dispatch through `events.create` - * instead, and this method is expected to be removed. See the - * `attributes-mvp` changelog entry for the migration shape. + * It remains on the interface, optional, so existing implementations keep + * compiling. The only callers were `workflow` 5.0.0-beta.8 through + * 5.0.0-beta.14, the attributes MVP, which wrote through this method. A + * World that may still be paired with one of those releases can keep its + * implementation. See `packages/core/src/attributes-mvp.md`. */ experimentalSetAttributes?( runId: string, diff --git a/skills/migrating-workflow-v4-to-v5/SKILL.md b/skills/migrating-workflow-v4-to-v5/SKILL.md index 00b2bc21da..bdd37cb052 100644 --- a/skills/migrating-workflow-v4-to-v5/SKILL.md +++ b/skills/migrating-workflow-v4-to-v5/SKILL.md @@ -4,7 +4,7 @@ description: >- Upgrades an app from Workflow SDK 4.x to 5.0. Use when bumping the `workflow` / `@workflow/*` dependencies to v5, or when hitting removed v4 APIs — `runStep`, `stepEntrypoint`, `workflow/internal/private`, `@workflow/core/private`, `writeToStream` / `closeStream` / `readFromStream` on a World, `world.steps.get` without a runId, `hook.getConflict()` returning `{ runId }`, `hook.metadata` read synchronously off a `getHookByToken()` result, `experimental_setAttributes`, `createLocalWorld` / `createVercelWorld`, `NestLocalBuilder` imported from `@workflow/nest`, or an SWC transform invoked with `mode: 'client'`. metadata: author: Vercel Inc. - version: '0.2.11' + version: '0.2.12' --- # Migrating Workflow SDK 4.x to 5.0 @@ -241,7 +241,7 @@ Beyond the stream and step signatures above, the interface gained: - `streams` as a namespace (see the table in step 2). - `analytics` — metadata-only listings for runs, steps, events, hooks, waits, and attributes. -- attribute support on runs, including `experimentalSetAttributes`. +- attribute support on runs: `attr_set` events and attributes on `run_created`. - capability advertisement, so the runtime can gate optimizations. Unadvertised capabilities fail closed, which means an incomplete World stays correct but slower — advertise a capability only once it is genuinely implemented. Event ID allocation is not among them: it is a requirement, not a capability. A World that rejected stale writes behind the old `preconditionGuard` flag can delete that code, since the flag is gone and nothing replaced it. - an optional per-run event ceiling returned on run reads, which the runtime enforces. - optional `createRunId()` and a `region` on queue options, for worlds that place run state regionally. diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md index decb339c0c..c4c7cc1853 100644 --- a/skills/migrating-world-v4-to-v5/SKILL.md +++ b/skills/migrating-world-v4-to-v5/SKILL.md @@ -4,7 +4,7 @@ description: >- Upgrades a custom Workflow SDK World implementation from the v4 spec, or from a 5.0.0 beta, to the current v5 spec (spec version 8). Use when a package implements the `World` interface from `@workflow/world` and is moving to 5.x, or is on a 5.0.0 beta and the runtime reports `This Workflow runtime supports Worlds with spec version 6 through 8, but the configured World declares spec version N`; or when it shows event IDs that are ULIDs rather than slot positions, `Event id is not slot-numbered` at replay time, a `specVersion` the runtime refuses, `writeToStream` / `closeStream` / `readFromStream` as top-level World methods, `steps.get` or `events.listByCorrelationId` without a `runId`, a `'step'` queue kind or `__wkf_step_*` topics, a `preconditionGuard` capability, or a `createLocalWorld` / `createVercelWorld` factory. metadata: author: Vercel Inc. - version: '0.4.1' + version: '0.4.2' --- # Migrating a World from the v4 spec to v5 @@ -40,7 +40,7 @@ Before editing, establish and report each of these: 2. **How event IDs are minted today.** Grep for `eventId`, `ulid`, `uuid`, `nanoid`, `nextval`, `AUTO_INCREMENT`, `IDENTITY`. Find the exact line that produces the ID written to storage. Note any place an ID is chosen before the write that stores it, for example an ID planned in advance and saved in a claim record so redeliveries reuse it. That design hands out a position before the commit and needs reworking, not porting. 3. **What settles a write race.** Read the `events.create` implementation. Note whether the ID or ordering is decided in process (read-then-write, an in-memory counter, a `Math.max` over loaded events) or in the store (unique constraint, conditional write, `INSERT ... ON CONFLICT`, a transaction). 4. **Which `specVersion` it declares.** Grep for `specVersion`. Note whether it is a literal or an imported constant. -5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`, `experimentalSetAttributes`. +5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`. Also note any `experimentalSetAttributes`: it is deprecated and the SDK no longer calls it (attributes arrive as `attr_set` events), so do not add one; an existing one can go unless the World must still serve `workflow` 5.0.0-beta.8 through beta.14. 6. **Whether it provisions step topics.** Grep for `'step'`, `__wkf_step`, `stepQueue`. 7. **Whether it rejects stale writes.** Grep for `PreconditionFailedError`, `preconditionGuard`, `stateUpdatedAt`, `stateEventCount`, `stateCursor`, `412`. 8. **Where its process-wide state lives.** Grep the World's modules for top-level `const`/`let` holding a pool, client, socket, registry, cache, ULID factory, or a log-once boolean. Note every one: these are correct in a `require()`d package and wrong in a bundled one. @@ -284,7 +284,7 @@ These change no signature. A World ported by types alone compiles and then behav None of this is required, and the runtime routes around each absence. Report what the World is missing rather than implementing everything unprompted. -`capabilities` (`hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, `maxConcurrency`, `dynamicWorkflowCode`, `inBandFence`), `analytics`, `analytics.events.getMany()`, `runs.experimentalSetAttributes`, `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `streams.createWriteSession()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. +`capabilities` (`hookRetention.active`, `hookResumeDedup`, `hookForceClaim`, `deploymentAffinity`, `maxConcurrency`, `dynamicWorkflowCode`, `inBandFence`), `analytics`, `analytics.events.getMany()`, `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `streams.createWriteSession()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. Four are worth raising unprompted because their absence is felt rather than reported: