Skip to content

functions: { fn: { handler, effect: 'writes' } } cannot survive objectstack build — lowering emits a shape FlowFunctionEntrySchema rejects #4976

Description

@xuyushun441-sys

Hit while implementing #4774's health-sweep job in examples/app-showcase. Authoring the declared functions entry — the spelling #4396 introduced so a writer keeps its run's metrics honest — makes objectstack build fail. The showcase works around it there by authoring the bare form, with a comment pointing here.

What happens

defineStack({functions: {sweepProjectHealth: {handler: sweepProjectHealth,effect: 'writes'},},});

pnpm --filter @objectstack/example-showcase build:

 → Lowering inline handlers...
→ Validating protocol compliance...
✗ Validation failed
functions:
✗ functions
invalid_union: Invalid input
1 validation error(s) total

Loading from source is fine — pnpm dev, objectstack validate, and the vitest suite all pass. Only the built path fails, which is the same asymmetry #4343 fixed for the bare form.

Why

lowerCallables correctly lowers a declared entry, keeping the declaration and replacing the callable with a ref. Its own test states the contract (packages/cli/src/utils/lower-callables.test.ts):

it('lowers a declared entry and keeps what it declared',()=>{constresult=lowerCallables({functions: {syncBilling: {handler: ()=>'synced',effect: 'writes'}},});expect(functionsOf(result).syncBilling).toEqual({handler: 'syncBilling',effect: 'writes'});});

So the value reaching the parse is { handler: 'syncBilling', effect: 'writes' } — a declaration whose handler is a string.

FlowFunctionEntrySchema (packages/spec/src/automation/flow-function.zod.ts ~137) has three members, and none accepts it:

exportconstFlowFunctionEntrySchema=lazySchema(()=>z.union([z.function(),// bare callable — authoredFlowFunctionDeclarationSchema,// { handler: z.function(), effect } — handler must be a CALLABLEz.string().min(1),// lowered BARE ref]));

Confirmed directly against the built spec:

lowered declared entry parses? false
invalid_union → [expected function, received object], [expected function, ...]
lowered BARE entry parses? true

#4396 taught the lowering step about declared entries; the schema union was not extended in the same change, so the fourth member — the lowered declaration — is missing. The module's own docblock describes the exact bug for the third member and stops one shape short:

> The first two are what an author writes. The third is what objectstack build produces and was, until #4343, the reason defineStack({ functions }) could not survive a build at all … The build failed on a mechanism its own docs call first-class.

Why it matters beyond the build failure

effect: 'writes' exists so an undeclared writer is not silently "counted as having written nothing" (#4396, #4354). Today an author who follows that guidance gets a build failure whose message (invalid_union: Invalid input, no path past functions) does not name the key, the entry, or the reason — so the practical outcome is that they delete the declaration and ship an undeclared writer, which is precisely the state the declaration exists to prevent.

Suggested direction

Add the lowered-declaration member to the union — a declaration whose handler may be a string ref, mirroring how the third member relates to the first. normalizeFlowFunctionEntry already drops string handlers (the callable rides in the sibling ESM module and is merged by name in collectBundleFunctionEntries), so the runtime half should need no change; worth confirming it keeps effect off the lowered record rather than dropping the entry.

A pin test in the shape of the existing lowering tests — lower a declared entry, then parse the result through FlowFunctionEntrySchema — would have caught this and would keep the two halves from drifting again. Right now no test crosses that boundary: lower-callables.test.ts asserts the emitted shape, and the spec tests parse only authored shapes.

Repro

cd examples/app-showcase
# author functions: { sweepProjectHealth: { handler: sweepProjectHealth, effect: 'writes' } }
pnpm build # ✗ functions: invalid_union
pnpm validate # ✓ passes — source path only

Observed on main @ f61c8cf.

Filed unassigned per Prime Directive #10.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions