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.tsneeded a matchingkind: []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.
| Location | Role today |
|---|---|
apps/sdlc/lib/registry.ts#invokeOp | The 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#OpHandler | Handler 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#evaluateTaskReady | Calls 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#runChild | A 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.yaml | Lists 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. |
Proposed
Section titled “Proposed”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.
Approach
Section titled “Approach”- Add
callOpandcallOpSynctoapps/sdlc/lib/registry.ts, next toinvokeOp, both generic over the descriptor’sI/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 runop.input.parse(input)and rethrow a Zod failure asOpError("INVALID_INPUT", ...), matchinginvokeOp’s taxonomy. Two decisions, settled here: (a) neither re-parses the handler result throughoutput— that staysinvokeOp’s adapter-boundary job, and the descriptor generics already type the return; (b)callOpSyncthrowsOpError("GENERIC", ...)naming the op path when the handler returns a thenable, becauseOpHandlerpermits an async handler and the two sync call sites cannot await. - Rewrite
evaluateTaskReadyinapps/sdlc/lib/services/orchestrator/ops/watch.tstocallOpSync(nextOp, { projectRoot: ctx.projectRoot, status: ["open/ready"], excludeAutonomy: ["human-only"] }, ctx), dropping theascast and the three fields that only restated schema defaults. Keep the surroundingtry/catchand the cycle handling unchanged. - Delete
runChildfromapps/sdlc/lib/model/entities/task/ops/gap-report.tsand callcallOpSyncat its call sites, removing theneverandTcasts. - Extend
apps/sdlc/lib/registry.test.tswith 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 raisingOpErrorwith codeINVALID_INPUT;callOpawaiting an async handler;callOpSyncthrowing on a thenable result; and a@ts-expect-errorline proving a call omitting the required field does not compile. - Record the rule in
apps/sdlc/lib/CLAUDE.mdalongside the existing “shell-outs go through the command seam” and “entity data goes through the model read layer” entries: in-process op invocation goes throughcallOp/callOpSync, never a bareop.handler(...);invokeOpstays the adapter path.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
apps/sdlc/lib/registry.ts | modify | Add callOp / callOpSync beside invokeOp, with the input-type and error-taxonomy contract from Approach step 1. |
apps/sdlc/lib/services/orchestrator/ops/watch.ts | modify | evaluateTaskReady 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.ts | modify | Remove the local runChild helper; call callOpSync instead. |
apps/sdlc/lib/registry.test.ts | modify | Cover default-filling, INVALID_INPUT, async handling, the sync-seam thenable guard, and the compile-failure case. |
apps/sdlc/lib/CLAUDE.md | modify | State the in-process invocation rule next to the existing seam rules. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
apps/sdlc/lib/registry.tsexportscallOpandcallOpSync, and acallOpSynccall that omits a field carrying.default()reaches the handler with that default applied (asserted inapps/sdlc/lib/registry.test.ts). - AC-2:
callOp/callOpSyncraiseOpErrorwith codeINVALID_INPUTwhen the input fails the descriptor’s Zod schema, asserted inapps/sdlc/lib/registry.test.ts. - AC-3:
apps/sdlc/lib/registry.test.tscarries a@ts-expect-errorcase showing acallOpSynccall that omits a required (non-defaulted) input field does not compile. - AC-4:
grep -rn "\.handler(" apps/sdlc --include="*.ts"returns matches only inapps/sdlc/lib/registry.tsand in files whose path ends in.test.tsor contains/tests/. - AC-5: Neither
apps/sdlc/lib/services/orchestrator/ops/watch.tsnorapps/sdlc/lib/model/entities/task/ops/gap-report.tscasts an op invocation result withasat the call site. - AC-6: Temporarily adding a new
.default()-carrying field to the input schema inapps/sdlc/lib/model/entities/task/ops/next.tsleavesbunx tsc --noEmitgreen with no edit towatch.ts(today the same edit produceserror TS2345atwatch.ts). - AC-7:
bunx tsc --noEmitandbun test apps/sdlcboth pass.
Out of scope
Section titled “Out of scope”- A lefthook
project-check-*hook that fails a commit on a bareop.handler(outside the registry and tests. AC-4’s grep is the manual form; wiring it intolefthook.ymlis its own task. - Making
bunx tsc --noEmita pre-commit or CI gate for the substrate. It runs today only as ansdlc.yamlquality check, and givingapps/sdlca moonchecktask (it declareslayer: tool, so it inherits none) is a separate change with its own CI-runtime tradeoff. - The
.handler(...)call sites inside*.test.tsand**/tests/**, which exercise handlers deliberately and often want to skip input parsing. invokeOp’s signature and the CLI/MCP adapter dispatch path inapps/sdlc/cli/registry_adapter.ts.
Dependencies
Section titled “Dependencies”- Sequencing only: T-JOXA-task-kind-field-and-leaf-dispatch edits the same
evaluateTaskReadycall site, so land this after that branch merges to avoid a conflict. No blocking relationship otherwise.
Discovery context
Section titled “Discovery context”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.