Skip to content

lowerCallables silently deletes any functions map entry it does not recognise — including the lowered declared form the schema explicitly accepts #7318

Description

@os-help

Observation-class finding, measured while implementing #6293. Filed unassigned per Prime Directive #10; not fixed in that PR (out of its scope).

What was measured

packages/cli/src/utils/lower-callables.ts, the map branch of the top-level functions lowering (lines ~150–173 on origin/main @ 5777b1a), does not EDIT the map — it rebuilds it into a fresh out object, and out admits an entry in exactly three shapes:

  • a callable — fn
  • an object whose handler is a callable — { handler: fn, effect }
  • a string — the "pre-existing string entries (legacy bundles)" pass below

Anything else is not rejected, not warned about, not passed through for the schema to judge. It is dropped, key and all, before the parse ever sees it.

Measured on examples/app-showcase, handing the lowering the husk a plain JSON.stringify of a stack leaves behind:

lowerCallables({ ...showcaseStack, functions: { sweepProjectHealth: { effect: 'writes' } } })
  → lowered.functions === {}          // the key is gone
  → ObjectStackDefinitionSchema.safeParse(lowered).success === true

Fed to the parse directly — the path #4976 exercised — that same husk is refused with invalid_union on functions.sweepProjectHealth, naming all four members. Fed through the lowering first, nothing is refused, because by parse time the evidence has been deleted.

Why it is worth recording

The dropped set is not only malformed input. It includes { handler: 'someRef', effect: 'writes' } — an already-lowered declared entry. handler is a string there, so neither branch matches and the string-only pass below tests typeof value === 'string' on the entry, not on entry.handler. That shape is not junk: FlowFunctionLoweredDeclarationSchema (packages/spec/src/automation/flow-function.zod.ts) exists precisely to accept it, added by #4976 so a built artifact could carry what a writer declared about itself. So the step that runs immediately BEFORE the parse deletes a shape the parse was taught to accept, which also makes the lowering non-idempotent: lower a stack twice and the declaration is gone the second time.

The consequence, if any path re-lowers an already-lowered functions map, is the exact harm the code's own comment two lines above says it must prevent — "dropping it here would silently un-declare the function on every built deployment while it kept working from source" — and an undeclared writer is counted as having written nothing, which is what keeps #4354's broken-sweep alert quiet on the run that needed it.

I did not establish that a user-reachable path re-lowers a lowered map today, so this is graded finding rather than a defect. What is established is that the failure mode is silent in a place where every neighbouring design decision (#4396, #4976, ADR-0049's enforce-or-remove posture) is explicitly about not being silent.

Prescription, for whoever takes it

Pass an unrecognised entry through to the parse instead of deleting it, so FlowFunctionEntrySchema gets to name what is wrong with it — and let the already-lowered declared form round-trip. A pin test belongs with it: lower a lowered stack and assert the functions key set is unchanged.

Where it was found

packages/qa/dogfood/test/build-shaped-artifact.ts (added by #6293's PR) works around this by reconciling the functions map key-for-key across the lowering and throwing when a key disappears — a stand-in guarding itself against the producer, which is worth replacing with the producer being loud.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions