Skip to content

T-U1KI-typecheck-in-process-op-call-args

Status: open/ready · Impact: medium · Complexity: small

AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-08-03 because the task was spawned non-interactively. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.

The two production in-process callers of an op handler build their args as a plain literal object and call op.handler(args, ctx) directly, bypassing the descriptor’s Zod input schema. Because z.infer is the schema OUTPUT type, a field carrying .default() is required at such a call site, so adding one to an op’s input schema breaks every direct caller and the caller has to restate a value the schema would have supplied. Route in-process invocation through a typed seam that parses input through the op’s own schema, so a defaulted field needs no caller edit and a genuinely required one fails to compile. Originates from T-JOXA-task-kind-field-and-leaf-dispatch on git@github.com:sksizer/dev.git.

Adding a field to an op’s zod input schema required hand-auditing every in-process caller of that op’s handler — apps/sdlc/lib/services/orchestrator/ops/watch.ts needed a matching kind: [] added to its literal args object, found only by manual search. Nothing flags a caller whose literal args object has drifted from the op’s input schema. Give in-process op invocation a typed seam (or a gate) so a stale caller fails typecheck or a check rather than silently passing an under-populated args object.

T-JOXA-task-kind-field-and-leaf-dispatch

LocationRole today
apps/sdlc/lib/registry.ts#invokeOpThe only validating invocation seam: async, path-keyed, resolves the op from the registry, parses rawInput through the descriptor’s Zod input (applying every .default()), runs the handler, re-parses the result through output. Returns Promise<unknown>, so a caller gets no output type — it is built for the CLI/MCP adapter boundary, not for a typed in-process call.
apps/sdlc/lib/registry.ts#OpHandlerHandler type is (input, ctx) where input is the schema’s z.infer — the OUTPUT type, in which a .default() field is required. A direct .handler() call must therefore spell out every field, including ones the schema would default.
apps/sdlc/lib/services/orchestrator/ops/watch.ts#evaluateTaskReadyCalls nextOp.handler({ projectRoot, status, excludeAutonomy, includeBlocked, limit, explain }, ctx) with a fully-spelled literal and an as cast on the result. Three of the six fields only restate schema defaults, and the cast discards the op’s real output type. This is the call site that needed a hand-added kind: [].
apps/sdlc/lib/model/entities/task/ops/gap-report.ts#runChildA file-local re-implementation of the same seam: op.input.parse({ projectRoot, task }) cast to never, then op.handler(parsed, ctx) as T. It does apply defaults, but the two casts erase both the input and the output contract, and it is private to one module.
sdlc.yamlLists bunx tsc --noEmit under quality_checks, and the root tsconfig.json includes apps/sdlc/**/*.ts — so the drift IS a compile error, but only at the end-of-task quality gate. No lefthook project-check-* hook runs it, and apps/sdlc/moon.yml declares layer: tool, so the inherited check task in .moon/tasks/ts-lib.yml never applies and CI’s moon run :check skips the substrate.

apps/sdlc/lib/registry.ts exports a generic in-process invocation seam beside invokeOp: callOp (async) and callOpSync (sync), each taking a defineOp descriptor, an input object typed as the schema’s INPUT type (defaulted fields optional), and an OpCtx; each parses the input through the descriptor’s own Zod schema and returns the descriptor’s output type. Both production direct callers use it, so neither restates a defaulted field nor casts the result. A later field added to an op’s input schema with a .default() needs no caller edit; one added without a default fails tsc at every call site.

  1. Add callOp and callOpSync to apps/sdlc/lib/registry.ts, next to invokeOp, both generic over the descriptor’s I/O. The input parameter is typed as the schema’s INPUT type (z.input), so fields carrying .default() may be omitted and fields without a default stay required; the return type is the schema’s output type, so no call-site cast is needed. Both run op.input.parse(input) and rethrow a Zod failure as OpError("INVALID_INPUT", ...), matching invokeOp’s taxonomy. Two decisions, settled here: (a) neither re-parses the handler result through output — that stays invokeOp’s adapter-boundary job, and the descriptor generics already type the return; (b) callOpSync throws OpError("GENERIC", ...) naming the op path when the handler returns a thenable, because OpHandler permits an async handler and the two sync call sites cannot await.
  2. Rewrite evaluateTaskReady in apps/sdlc/lib/services/orchestrator/ops/watch.ts to callOpSync(nextOp, { projectRoot: ctx.projectRoot, status: ["open/ready"], excludeAutonomy: ["human-only"] }, ctx), dropping the as cast and the three fields that only restated schema defaults. Keep the surrounding try/catch and the cycle handling unchanged.
  3. Delete runChild from apps/sdlc/lib/model/entities/task/ops/gap-report.ts and call callOpSync at its call sites, removing the never and T casts.
  4. Extend apps/sdlc/lib/registry.test.ts with a fixture op whose input has one defaulted field and one required field, covering: the defaulted field filled when omitted; an input violating the schema raising OpError with code INVALID_INPUT; callOp awaiting an async handler; callOpSync throwing on a thenable result; and a @ts-expect-error line proving a call omitting the required field does not compile.
  5. Record the rule in apps/sdlc/lib/CLAUDE.md alongside the existing “shell-outs go through the command seam” and “entity data goes through the model read layer” entries: in-process op invocation goes through callOp / callOpSync, never a bare op.handler(...); invokeOp stays the adapter path.
LocationKindChange
apps/sdlc/lib/registry.tsmodifyAdd callOp / callOpSync beside invokeOp, with the input-type and error-taxonomy contract from Approach step 1.
apps/sdlc/lib/services/orchestrator/ops/watch.tsmodifyevaluateTaskReady invokes the next op through callOpSync; the literal restatement of defaulted fields and the as cast go away.
apps/sdlc/lib/model/entities/task/ops/gap-report.tsmodifyRemove the local runChild helper; call callOpSync instead.
apps/sdlc/lib/registry.test.tsmodifyCover default-filling, INVALID_INPUT, async handling, the sync-seam thenable guard, and the compile-failure case.
apps/sdlc/lib/CLAUDE.mdmodifyState the in-process invocation rule next to the existing seam rules.
  • AC-1: apps/sdlc/lib/registry.ts exports callOp and callOpSync, and a callOpSync call that omits a field carrying .default() reaches the handler with that default applied (asserted in apps/sdlc/lib/registry.test.ts).
  • AC-2: callOp / callOpSync raise OpError with code INVALID_INPUT when the input fails the descriptor’s Zod schema, asserted in apps/sdlc/lib/registry.test.ts.
  • AC-3: apps/sdlc/lib/registry.test.ts carries a @ts-expect-error case showing a callOpSync call that omits a required (non-defaulted) input field does not compile.
  • AC-4: grep -rn "\.handler(" apps/sdlc --include="*.ts" returns matches only in apps/sdlc/lib/registry.ts and in files whose path ends in .test.ts or contains /tests/.
  • AC-5: Neither apps/sdlc/lib/services/orchestrator/ops/watch.ts nor apps/sdlc/lib/model/entities/task/ops/gap-report.ts casts an op invocation result with as at the call site.
  • AC-6: Temporarily adding a new .default()-carrying field to the input schema in apps/sdlc/lib/model/entities/task/ops/next.ts leaves bunx tsc --noEmit green with no edit to watch.ts (today the same edit produces error TS2345 at watch.ts).
  • AC-7: bunx tsc --noEmit and bun test apps/sdlc both pass.
  • A lefthook project-check-* hook that fails a commit on a bare op.handler( outside the registry and tests. AC-4’s grep is the manual form; wiring it into lefthook.yml is its own task.
  • Making bunx tsc --noEmit a pre-commit or CI gate for the substrate. It runs today only as an sdlc.yaml quality check, and giving apps/sdlc a moon check task (it declares layer: tool, so it inherits none) is a separate change with its own CI-runtime tradeoff.
  • The .handler(...) call sites inside *.test.ts and **/tests/**, which exercise handlers deliberately and often want to skip input parsing.
  • invokeOp’s signature and the CLI/MCP adapter dispatch path in apps/sdlc/cli/registry_adapter.ts.

Spawned by /sdlc:spawn-task-pr on 2026-08-03 UTC from T-JOXA-task-kind-field-and-leaf-dispatch in git@github.com:sksizer/dev.git.


← Back to Tasks