Typed workflows: user-declared types on the protobuf + CEL substrate, one type system everywhere
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
Today inputs:/outputs: declare scalar-ish types, and everything richer is a map a reader must hold in their head. The vision this issue records: workflow authors declare named, structured types — message shapes, enums where they earn it — and those types flow through every surface the system already has, because they are built on the two type systems the system already runs on. The rule that makes this visionary rather than sprawling: reuse, never reinvent. Protobuf is the type definition language; CEL's native proto support is the expression-side semantics; everything else derives.
The convergence, concretely
A declared type compiles to a real protobuf descriptor (descriptorpb/dynamicpb — the same machinery the IR itself uses). From that one artifact, with no second type system anywhere:
- CEL gets it natively — cel-go registers proto messages and enums as first-class types, so
${inputs.order.customer.region}type-checks throughenv.Checkexactly as built-in types do today, field access is checked not stringly, and enum comparisons are symbolic. This is reuse of cel-go's existing proto integration, not an extension to it. - The LSP gets it for free — hover shows the declared shape; a misspelled field is a positioned diagnostic at the expression;
call:'s typed-function feel (#172) deepens: binding awith:argument against a callee's declared message type is static structural checking, the sameenv.Checkpath. - Plugins already live here — a plugin manifest describing typed task inputs/outputs against descriptors is the plugin system's native tongue (separate processes compiling against public descriptors — ARCHITECTURE's own words). User types and plugin types are the same kind of thing; a workflow output typed
deploy.Reportcan be a plugin input typed the same, checked at compile. - RPC/API/MCP cohesion falls out — a workflow whose inputs and outputs are proto messages is a typed operation signature. The early roadmap said
inputs:/outputs:were written to be "what a future Nexus operation signature reads from"; this completes that thought: the same descriptors can render a connect service shape, an MCP tool schema (JSON Schema derived from descriptors — machinery that exists in the ecosystem), and documentation. Workflows as typed services is not a new subsystem — it is a projection of descriptors the spec already carries. - Evolution safety is protobuf's own — field-number discipline, add-only compatibility, and
buf breakingapplied to user schemas exactly as CI applies it to ours. We inherit thirty years of schema-evolution doctrine instead of inventing versioning. Per-run semantics stay trivially safe because a run's spec is frozen (invariant 10): types travel inside the spec like a callee does, so a run is hermetically self-describing; evolution questions exist only at authoring boundaries, where buf-style checking answers them.
Spelling sketch (illustrative, not committed)
types:
Order: # or an import of a real .proto file — both compile to descriptors
fields:
id: string
region: { enum: [US, EU, APAC] }
items: { repeated: LineItem }
inputs:
order: Order
Whether the DSL grows an inline types: block, imports .proto files directly (maximum reuse — your existing schemas just work), or both, is a real design choice to make deliberately — inline is ergonomic for small shapes, .proto import means an org's existing contracts are immediately workflow signatures. Likely both, with inline compiling to the identical artifact.
The gates (Go-stdlib discipline, per #157)
- Worth-it threshold: this lands when real files show maps straining — deep field access unchecked, the same shape re-described in prose across workflows, a plugin boundary wanting structure. The examples portfolio (#165) and library workflows (#172) will surface it honestly.
- Enums only if they earn it — they likely do (status vocabularies are everywhere and stringly today), but they ride the message work, not ahead of it.
- No second type system, ever — the acceptance test for every slice: if a piece of type behavior isn't protobuf's or CEL's, it doesn't ship. The moment we're writing our own structural-typing rules, we've failed the issue's premise.
- Sizes still bounded — a typed value is still a value crossing history;
CheckSpecSize/CheckRunStateSizeunchanged, and descriptors count against spec size (they're part of the frozen spec). - Sequencing: after the current wave (call: merged, loop:/select designs) and informed by #175's recorded ergonomics gaps. First slice when triggered: descriptors-in-spec + CEL registration + typed
inputs:— the LSP/plugin/service projections follow as separate slices, each reading the same artifact.
Related: #172 (typed call signatures), #157 (language slate + DSL coherence audit), #174 (workflows as services — the reflexive plugin would speak these same descriptors), #165 (the portfolio as the demand signal), #153 (LSP feedback).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reviewing the current call work, #172, #157, and #175, then trace the existing CheckSpecSize/CheckRunStateSize paths and descriptorpb/dynamicpb usage. The first slice is descriptors in the frozen spec, CEL registration, and typed inputs; completion requires reuse of protobuf and CEL semantics without a second type system, while later LSP, plugin, and service projections remain separate slices.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend-api-design, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100