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', () => {
  const result = 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:

export const FlowFunctionEntrySchema = lazySchema(() => z.union([
  z.function(),                    // bare callable — authored
  FlowFunctionDeclarationSchema,   // { handler: z.function(), effect } — handler must be a CALLABLE
  z.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.

Activity

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    分诊:入队 pm:queue,域 domain:spec。

    • 落点锚定:packages/spec/src/automation/flow-function.zod.ts 的 FlowFunctionEntrySchema 补第四个 union 成员(lowered declaration:handler 为 string ref 的声明形),外加正文点名的跨界 pin 测试(lower → parse)。触 packages/spec,按「shared contract surfaces have one owner」归 spec 座位。
    • 过时前提检查:origin/main 上该文件与 packages/cli/src/utils/lower-callables.ts 自 issue 立单后无相关改动,前提成立。
    • 范围:runtime 半边(normalizeFlowFunctionEntry 对 string handler 的处理、effect 是否保留)按正文核对,预计同 PR 内完成,无需 contract-first 拆分(消费侧无行为变化,只是让 build 产物重新可解析)。
    • 查重:仅 [P0] build-time vs runtime validation parity: objectstack build accepts metadata the runtime rejects #1876(closed,build/runtime parity 总纲)历史相关,无 open 重复。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions