## What
One new live-PostgreSQL E2E suite, 4 tests. **No production file is
touched** — evidence, per the E2E worker's remit. Third in the series
after #2789 and #2791; same root cause, materially worse consequence.
`packages/engine/src/__tests__/workflow-custom-fields-sync-resolution-live-e2e.pg.test.ts`
## The finding
**A workflow that declares custom fields cannot have any of them
written.**
`TaskStore.resolveTaskCustomFieldDefsSync` reads a task's field
definitions through `store.resolveTaskWorkflowIrSync`, which under
PostgreSQL answers `undefined` for every task and therefore resolves the
**default** workflow IR. The default declares no `fields`, so the
function returns `[]` for every task on every board. `task-update.ts`
validates every write against that empty list:
```ts
const defs = store.resolveTaskCustomFieldDefsSync(id);
const result = validateCustomFieldPatch(defs, updates.customFields);
if (!result.ok) throw new CustomFieldRejectionError(result.rejection);
```
Observed against a real store with a real persisted workflow declaring
one `text` field:
```
STORED fields = [{"id":"risk","name":"Risk","type":"text"}]
SYNC defs = []
WRITE threw = CustomFieldRejectionError
custom field 'risk' rejected (no-fields-defined):
the resolved workflow declares no custom fields; no values may be written
```
The rejection message is a true statement about the workflow that got
resolved and a false one about the workflow the card is on.
### The two halves of the feature disagree in production
The executor resolves the same definitions through the **async**
resolver (`executor.ts` → `resolveTaskCustomFieldDefs` →
`resolveWorkflowIrForTask`) and sees the real field. So an agent can be
prompted to supply a value that the store will then refuse to store. The
last case asserts both answers against **one store, one task, one
workflow** — which is why this cannot be dismissed as a fixture
artefact.
This is a different severity from the previous two PRs in the series.
#2789 and #2791 are wrong-lane defects, mostly latency, one of them
unbounded. This one is a declared feature that does not function off the
default board.
## Scope on record
Three write paths share the sync resolver: `task-update.ts` (driven
here), `workflow-task-create-ops.ts:394`, and `workflow-ops.ts:488`.
Only the first is exercised; the other two are named in the file so the
surface is recorded rather than implied.
Also worth stating plainly: because the empty list *is* the default IR's
`fields`, the same rejection is what a default-board card gets too. The
feature is not merely renamed-board-broken.
## Evidence discipline
- **Fixture integrity first.** The opening case asserts the stored
workflow really does declare the field via the async resolver. Every
other assertion is about a *missing* definition and would pass just as
well against a workflow that never declared one — that case is what
makes the rest mean something.
- **Observed state.** The thrown typed rejection plus the **absence** of
a persisted value on a re-read row. No spy on the validator.
- **Mutation-verified.** Replacing the sync resolver's body with a
hardcoded `[{id:"risk",…}]` fails **3 of 4** arms. The fourth is the
fixture-integrity case, which exercises the async path by design and
correctly survives.
## Not done, and why
**No fix.** The async resolver already exists and is already used by the
executor for the same data, so the shape of the fix is clear — but
`task-update.ts`'s validation runs inside a synchronous update path, and
making it async is a behaviour decision in `@fusion/core` that belongs
to that file's owner, not to a smuggled edit in an evidence PR. The
call-site allow-list entry for `task-store-helpers.ts` ("Synchronous
helper shared by txn-hot paths") should cite this suite either way: the
entry is accurate about the constraint and silent about the cost.
## Verification
- new suite — **4/4 passed**, mutation-verified 3/4 (fourth by design)
- full live-PG E2E surface, 20 suites — **125/125 passed** (121 on main
+ 4)
- `pnpm lint` — clean
Lane: `.pg.test.ts`, skipped via `pgDescribe` when no PostgreSQL is
reachable, so the merge gate is unaffected. Throwaway per-file database;
never port 4040.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>