Skip to content

fix(workflow-executor): coerce the pre-recorded update value, invalid one is a configuration error - #2008

Merged
christophebrun-forest merged 13 commits into
mainfrom
feature/prd-628-executor-coerce-pre-recorded-value
Oct 30, 2026
Merged

christophebrun-forest merged 13 commits into
mainfrom
feature/prd-628-executor-coerce-pre-recorded-value

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Part of PRD-628 (batch 1/4: executor)

Problem

On an Update Data step pinned with fieldName + value, the build-time value was written as is. The AI paths and the user override coerce the value to the field's type through coerceFieldValue, so an invalid enum or a malformed date could reach the datasource unchecked on the automatic path only.

Change

  • handleFirstCall resolves the pinned field first, then runs the pre-recorded value through coerceFieldValue before the FullyAutomated / confirmation split.
  • New InvalidPreRecordedValueError (WorkflowConfigurationError → errorKind: 'configuration'). Nothing is written. The technical message (logs) carries the zod reason; the user message names the field only: The value set on this step can't be written to the "Status" field. Edit the step to set a valid value.
  • Relationship field → new RelationshipFieldNotUpdatableError (configuration), pinned with or without a value (null included) or overridden at accept. updateRecord sends attributes only, and the agent's RecordValidator rejects a relation there ("The record data is empty" / "Unexpected schema type"), so nothing can be unlinked this way. User message: The "Orders" field is a relationship, which this step can't update. Edit the step to pick another field. A configuration kind keeps the front's contact-an-Admin banner and offers no rewind (forestadmin ADR 2026-09-23), which fits: only an Admin can change the step.
  • Override of a field removed from the schema between pause and accept → new PendingFieldNotFoundError (configuration, schema drift like FieldTypeMissingError) instead of a raw write.
  • Diagnostics: a rejected Number logs expected a number or a decimal string, e.g. 12, -3 or 12.5 on both the strict and lenient paths (it used to log a bare Invalid input, or leak the union's regex). Technical message only; the user message is unchanged.
  • coerceFieldValue takes options { strict, toError } and now requires a resolved field; the AI path throws FieldNotFoundError before coercing, also when the AI names a relationship (the operator is told to rephrase the prompt, not to edit a step that pins nothing).

Coercion rules

AI tool schema Coercion (AI value, pending value accepted as is) Person-written value (strict: pre-recorded, override)
Date Z only, as on main Z or offset Z or offset. No zone → rejected
Number z.coerce.number(), as on main as main ("" → 0, true → 1) decimal strings only ("12", "-3", "12.50"). Blank, booleans, arrays, 0x10, 1e3, Infinity → rejected. A zero fraction is dropped ("9007199254740993.0" → "9007199254740993", kept exact); a real fraction a double cannot hold digit for digit ("123456789.123456789", "4503599627370496.5", "12345678901234567890.5") → rejected instead of rounded
Json JSON string, as on main any JSON value (string, object, array, boolean, number) any JSON value (string, object, array, boolean, number)
Point [lon, lat], as on main [lon, lat] or "lon,lat", written as "lon,lat" [lon, lat] or "lon,lat", written as "lon,lat"

An integer string beyond Number.MAX_SAFE_INTEGER (a bigint column, e.g. "12345678901234567890") is written as the string, on every coercion path: the agent accepts a string on a Number column only when BigInt() parses it. Every other decimal string is converted to a number ("0.0000001" → 1e-7). A Point is written as the "lon,lat" string because the agent's TypeGetter takes no other shape: on main the array from the AI or an override was rejected by the agent, and only a pinned "lon,lat" got through. Contract for the front batches: a Date value must carry Z or an offset.

A pending value accepted without override is coerced leniently. The current front never takes that path: the run view's accept (submitFieldUpdate) always sends { value, userConfirmed: true }, with the staged value falling back to the suggestion, so every accept from the front is coerced strictly. The lenient path only serves a client that accepts without sending a value.

Behavior notes for reviewers

  • AutomatedWithConfirmation + pre-recorded value: the coerced value is now what goes into pendingData. An invalid value fails at the first call as a configuration error, where it used to fail at accept time with a generic StepStateError.
  • Pre-recorded value on a field with no column type: this now raises FieldTypeMissingError (configuration), as the override path already does.
  • User override of "" or a non-decimal string on a Number: now fails with StepStateError instead of being written. On a relationship field: fails with RelationshipFieldNotUpdatableError (configuration).
  • Accepts from the current run view stay valid under strict coercion. On forestadmin main, toExecutorValue sends a Number as a number (blank → 0 before sending), a Date as toISOString() or the suggestion as stored, a Dateonly as YYYY-MM-DD and Json as a string. Precision on a bigint column is still lost on accept, because the front's toNumber rounds the pending string; main rounded it too.
  • AI path: the tool's JSON Schema is a hint to the model only (invokeWithTool returns the raw tool args), so coerceFieldValue is the runtime check. AI values now go through the non-AI schemas: a Date with an offset and a non-string Json value are written, and a bigint string is kept exact. "" → 0 and true → 1 on a Number are unchanged.
  • Executor version floor: the release that ships this PR is the floor for the server (forestadmin-server#8609) and the front (forestadmin#10090). 1.33.2 is already published without this change, so the floor is the version tagged after merge.
  • The editor (forestadmin main) has no value input yet and the server forwards value verbatim, so today only a hand-edited BPMN reaches this path.

Tests

update-record-step-executor.test.ts:

  • Pre-recorded: valid/invalid enum (exact kind and message), invalid enum under confirmation (no pendingData), datetime with Z, with an offset, without a zone, impossible date, bigint strings kept exact ("12345678901234567890", negative too), "0.0000001" → 1e-7, "12.50", "007", "-3", a zero fraction dropped ("9007199254740993.0" and "-12345678901234567890.000" kept as exact integer strings, "12.0" → 12), a fraction a double cannot hold rejected ("12345678901234567890.5", "4503599627370496.5", "123456789.123456789", "0.1000000000000000000001", "1.00000000000000000001"), Point "2.35,48.85" and [2.35, 48.85] written as "2.35,48.85", malformed Points rejected, blank / boolean / array / non-decimal strings rejected, the expected number format named in the logged message only, Json object, array, string, boolean and number written as is, object on a composite field written as is, invalid JSON string rejected, relationship field rejected for "42", null and no value, type-less field → FieldTypeMissingError, 0, false/"false", null.
  • Accept / override: blank, hex and a fraction beyond the safe range on a Number rejected, a relationship value (null included) rejected as configuration, removed field → PendingFieldNotFoundError (configuration, logged message checked). The run view's real accept (pending value sent back as value) is coerced strictly: a pending "" is rejected, a pending bigint string stays exact. A client accepting without sending a value gets lenient coercion: pending blank Number → 0, offset Date, Json object and bigint string written as is.
  • AI (executor level, invokeWithTool returns unparsed args): an offset Date, a Json object / boolean / number and a bigint string are written as is; ""/true on a Number still written 0/1; a non-number logs the expected format; a Point array is written as "lon,lat"; a relationship named by the AI fails with FieldNotFoundError (not configuration), nothing written. A separate test checks only the tool schema sent to the model: it accepts Z and rejects an offset on input.value.

Red-check: the two diagnostic tests fail against the previous number coercion, the four safe-range tests fail without the zero-fraction / beyond-range rule, and the 12 tests added for the lossy-decimal, Point and AI-relationship fixes fail without them.

Package tests (2686 passed, 8 skipped), lint (0 errors) and tsc are green; every line this PR adds to update-record-step-executor.ts and errors.ts is covered.

🤖 Generated with Claude Code

Note

Add strict coercion and configuration errors for update-record pre-recorded values

  • Pre-recorded update values now go through strict validation: Date values need a timezone, Json values may be JSON strings or parsed values, and integer strings too large for a JS number stay as strings
  • Invalid pre-recorded values, pinned relationship fields, and pending fields missing from the schema now fail as configuration errors with new InvalidPreRecordedValueError, RelationshipFieldNotUpdatableError, and PendingFieldNotFoundError in errors.ts
  • Confirmation flow distinguishes an explicit override (strict coercion, null allowed for ordinary fields) from accepting the pending value (lenient coercion)
  • Behavioral Change: relationship fields are no longer writable via update-record overrides, including null overrides; AI-tool Date and Json schemas remain restricted (no timezone offsets, string-only Json) while runtime schemas are lenient

Changes since #2008 opened

  • Changed strict numeric coercion in the update-record executor to reject values a double cannot represent digit-for-digit [ecfbbe8]
  • Added runtime coercion of Point values to the lon,lat string in the update-record executor [ecfbbe8]
  • Changed UpdateRecordStepExecutor to throw FieldNotFoundError when the AI selects a relationship field [ecfbbe8]
  • Extended the update-record-step-executor tests for numeric coercion, Point coercion, and relationship-field handling [ecfbbe8]
  • Updated the Pre-recorded args section of the workflow-executor CLAUDE.md [ecfbbe8]

Macroscope summarized dc63ca0.

@linear-code

linear-code Bot commented Oct 15, 2026

Copy link
Copy Markdown

PRD-628

@qltysh

qltysh Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

8 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): buildZodSchemaForPrimitive 4
qlty Structure Function with many returns (count = 10): buildZodSchemaForPrimitive 2
qlty Structure High total complexity (count = 77) 1
qlty Structure Function with high complexity (count = 12): buildNumberCoercion 1

@qltysh

qltysh Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.1%.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...workflow-executor/src/executors/update-record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

christophebrun-forest and others added 2 commits October 27, 2026 11:28
… one is a configuration error

A build-time fieldName + value on an Update Data step was written as is, while
the AI and user-override paths coerce it to the field's type. It now goes
through coerceFieldValue too; a value the field's type rejects raises
InvalidPreRecordedValueError (errorKind: configuration) naming the field and
the reason, and nothing is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…umber when coercing an update value

A Date value with a timezone offset (+02:00) was rejected while it names an
exact instant; it is now accepted, a datetime without any zone still fails.
A blank string on a Number field was coerced to 0 and written; it now fails
like any other non-numeric string. Both apply to the pre-recorded and
override paths; the AI tool schema keeps plain z.coerce.number().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@christophebrun-forest
christophebrun-forest force-pushed the feature/prd-628-executor-coerce-pre-recorded-value branch from 3679c17 to 4b96ea5 Compare October 27, 2026 10:31
christophebrun-forest and others added 3 commits October 27, 2026 11:52
…ue on a relationship field

z.coerce.number() still read false and [] as 0 and true as 1, and a relationship field skipped
coercion, so both were written raw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…I path's coercion rules

The stricter Date and Number rules now apply only to a value a person wrote (pre-recorded or
overridden), so the AI tool schemas and AI-returned values match the front again. The
pre-recorded value error no longer surfaces zod's wording to the end user.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nd keep number and json values exact

A relationship can't be updated through the agent's attribute route, null included, so a pinned
relationship field fails as a configuration error with or without a value. A decimal string a
double would round is written as is, a Json object or array is accepted, and non-decimal strings
are rejected on the person-written paths. A pending value accepted without override is coerced
leniently so rows saved by an older executor still go through, and a pending field removed from
the schema fails instead of being written raw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread packages/workflow-executor/src/executors/update-record-step-executor.ts Outdated
christophebrun-forest and others added 7 commits October 28, 2026 16:06
… json scalars

The agent takes a string on a Number column only as an integer, so only an integer too large for
a double stays a string. A Json column accepts any value, so booleans and numbers pass too. Also
covers a composite field and drops an unreachable fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ip override as configuration

Both come from the schema or the step, which only an Admin can change, so they keep the front's
contact-an-Admin banner instead of the generic unexpected-error message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…is rejected

The strict path logged a bare "Invalid input" and the lenient one leaked the union's regex. Both
now name the accepted shapes; the user message is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…view's accept is coerced

invokeWithTool returns the tool args unparsed, so coerceFieldValue is the runtime check on AI
values: pin an offset Date, Json scalars and objects, and a bigint string at executor level. The
run view accepts by sending the pending value back, which takes the strict path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an unsafe fraction

On the strict path a decimal string with only zeros after the point becomes its integer string,
which the agent's BigInt check accepts, and a real fraction beyond Number.MAX_SAFE_INTEGER is
rejected instead of being silently rounded. The lenient and AI paths are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the safe range

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@PMerlet PMerlet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec (PRD-628): conforms. A pinned value is coerced before the Automatic write, and an invalid one fails the step without writing.

Comment thread packages/workflow-executor/src/executors/update-record-step-executor.ts Outdated
Comment thread packages/workflow-executor/src/executors/update-record-step-executor.ts Outdated
…s and AI-chosen relationships

- A Point takes [lon, lat] or "lon,lat" and is written as the "lon,lat"
  string, the only shape the agent's TypeGetter accepts. A pinned
  "lon,lat" value was rejected since the coercion landed.
- A strict decimal with a real fraction is rejected whenever a double
  cannot hold every significant digit, not only beyond
  MAX_SAFE_INTEGER ("123456789.123456789", "4503599627370496.5").
- A relationship named by the AI raises FieldNotFoundError, which tells
  the operator to rephrase the prompt, instead of the configuration
  error meant for a pinned field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@PMerlet PMerlet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after /verify-fixes run 2008-20261030082124-PMerlet-verify: 3 findings of 2008-20261029153303-PMerlet closed or accepted, none reopened.

@christophebrun-forest
christophebrun-forest merged commit 996d623 into main Oct 30, 2026
39 checks passed
@christophebrun-forest
christophebrun-forest deleted the feature/prd-628-executor-coerce-pre-recorded-value branch October 30, 2026 09:08
forest-bot added a commit that referenced this pull request Oct 30, 2026
## @forestadmin/workflow-executor [1.35.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/workflow-executor@1.35.3...@forestadmin/workflow-executor@1.35.4) (2026-10-30)

### Bug Fixes

* **workflow-executor:** coerce the pre-recorded update value, invalid one is a configuration error ([#2008](#2008)) ([996d623](996d623))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants