Problem
The controller's internal/task/ defines a taskParamser interface (internal/task/sidecar.go:16-19) implemented by 14 task-params types:
type taskParamser interface {
toRequestParams() *map[string]any
taskType() string
}
Each *Params type has a toRequestParams() method that hand-builds a *map[string]any from its fields — duplicating exactly what json.Marshal of the same struct already produces via its json:"camelCase" tags. Pure ceremony.
The sidecar/client package in seictl (github.com/sei-protocol/seictl/sidecar/client/tasks.go) provides typed Task wrappers (SnapshotRestoreTask, ConfigureGenesisTask, AssembleAndUploadGenesisTask, etc.) implementing a parallel TaskBuilder interface:
type TaskBuilder interface {
TaskType() string
Validate() error
ToTaskRequest() TaskRequest
}
These are the canonical ergonomic shape — single source of truth for wire format AND validation, with strict bech32 / shape checks living next to the field declarations.
The two interfaces overlap in purpose. The controller-side taskParamser predates the seictl-side TaskBuilder and is now redundant for tasks that have a typed wrapper.
Impact
Drift hazard. When a task gets a new field, both the seictl-side wrapper AND the controller-side params shell must be updated. We just hit this in #160 — the sidecar PR shipped the server-side change but missed the client-side typed wrapper, and the controller-side scaffolding in turn duplicated the wire format with a hand-rolled bech32 check. Caught in review (a third PR + this follow-up), but the gap is structural.
Onboarding friction. A new task type today requires duplicate work: server impl in seictl/sidecar/tasks/, client wrapper in seictl/sidecar/client/tasks.go, AND a controller-side params shell in sei-k8s-controller/internal/task/. The third is mechanical and adds no value.
Operator visibility. PlannedTask.Params.Raw storage uses lowercase camelCase via the controller's json tags. Migrating to client.*Task directly (without json tags on the wrappers) would change the storage format to capital field names — operator-visible cosmetic regression unless the wrappers also gain json tags.
Relevant experts
kubernetes-specialist — controller-runtime conventions, runtime.RawExtension round-trip semantics, registry-based deserialization
platform-engineer — sidecar client API surface, breaking-change strategy for the seictl module
Proposed approach
Phased migration; can be done incrementally per task type.
Phase 1 — sidecar client surface
For each of the 14 task types:
- Confirm a
client.*Task typed wrapper exists. Add one if it doesn't (e.g., ConfigPatch, ConfigureStateSync already exist; verify the rest).
- Add
json:"camelCase" tags to the typed wrapper fields so direct json.Marshal produces the same wire format the existing toRequestParams() produces. Drop TaskMeta.ID from JSON output via json:"-" since it's controller-internal metadata, not wire payload.
- Cut a seictl release with the json-tagged wrappers.
Phase 2 — controller migration
- Extend the controller's executor (
internal/task/sidecar.go) to accept client.TaskBuilder directly, calling ToTaskRequest() to build the sidecar.TaskRequest. Override Id with the deterministic task ID at submit time.
- For each migrated task type:
- Drop the controller's
*Params shell.
- Update planner construction sites to use
client.*Task{...} directly.
- Update
Deserialize registry to unmarshal into the typed wrapper.
- Update tests.
- Remove
taskParamser interface and the compile-time assertion list once all 14 are migrated.
Phase 3 (optional) — drop the JSON round-trip
Today: planner builds typed struct → json.Marshal → PlannedTask.Params.Raw → json.Unmarshal back into typed struct → toRequestParams() → JSON-marshal the map → wire bytes. Two round-trips for the same payload.
Cleaner: planner builds typed wrapper → ToTaskRequest() → store the Params map directly in PlannedTask.Params.Raw → executor reads bytes and submits verbatim. One round-trip.
Worth doing only if Phase 1+2 reveal it's cheap — the executor's current e.params T shape is generic, so a refactor to "store wire bytes" needs care.
Architectural constraints
TaskMeta.ID field on every typed wrapper must serialize as json:"-" (or be unexported) so it doesn't pollute PlannedTask.Params.Raw.
- Storage format compatibility. Existing in-flight TaskPlans with
Params.Raw in the old shape need to deserialize cleanly. If field names change (capital ↔ lowercase), the deserialization will silently produce zero-valued fields. Migrating in two steps (add json tags first, then migrate consumers) avoids this.
- One-way door: removing the controller's
*Params types is breaking for any tooling that imports them. None known today; verify before commit.
Acceptance criteria
Out of scope
- Adding new task types to
seictl/sidecar/client beyond the existing 14.
- CRD schema changes.
- Migrating non-sidecar task executions (e.g., controller-internal tasks like
CreateExporter) — they don't go through the sidecar HTTP API and don't use taskParamser.
References
sei-protocol/sei-k8s-controller#160 introduced the delegating-shell pattern (Phase 0): controller's AssembleAndUploadGenesisParams and AssembleForkGenesisParams keep their shell but delegate Validate() and toRequestParams() to client.AssembleAndUploadGenesisTask / client.AssembleGenesisForkTask. This issue carries the migration to its logical conclusion.
sei-protocol/seictl#121 added the typed wrappers + strict bech32 validation for the assemble-genesis tasks.
internal/task/sidecar.go:13-37 — taskParamser interface + compile-time assertions for the 14 implementing types.
internal/task/sidecar.go:73-87 — executor's current dispatch site that would shift to TaskBuilder.
internal/task/task.go:248-262 — deserializeSidecar[T] generic helper that hydrates e.params T from Params.Raw.
🤖 Generated with Claude Code
Problem
The controller's
internal/task/defines ataskParamserinterface (internal/task/sidecar.go:16-19) implemented by 14 task-params types:Each
*Paramstype has atoRequestParams()method that hand-builds a*map[string]anyfrom its fields — duplicating exactly whatjson.Marshalof the same struct already produces via itsjson:"camelCase"tags. Pure ceremony.The
sidecar/clientpackage inseictl(github.com/sei-protocol/seictl/sidecar/client/tasks.go) provides typedTaskwrappers (SnapshotRestoreTask,ConfigureGenesisTask,AssembleAndUploadGenesisTask, etc.) implementing a parallelTaskBuilderinterface:These are the canonical ergonomic shape — single source of truth for wire format AND validation, with strict bech32 / shape checks living next to the field declarations.
The two interfaces overlap in purpose. The controller-side
taskParamserpredates the seictl-sideTaskBuilderand is now redundant for tasks that have a typed wrapper.Impact
Drift hazard. When a task gets a new field, both the seictl-side wrapper AND the controller-side params shell must be updated. We just hit this in #160 — the sidecar PR shipped the server-side change but missed the client-side typed wrapper, and the controller-side scaffolding in turn duplicated the wire format with a hand-rolled bech32 check. Caught in review (a third PR + this follow-up), but the gap is structural.
Onboarding friction. A new task type today requires duplicate work: server impl in
seictl/sidecar/tasks/, client wrapper inseictl/sidecar/client/tasks.go, AND a controller-side params shell insei-k8s-controller/internal/task/. The third is mechanical and adds no value.Operator visibility.
PlannedTask.Params.Rawstorage uses lowercase camelCase via the controller's json tags. Migrating toclient.*Taskdirectly (without json tags on the wrappers) would change the storage format to capital field names — operator-visible cosmetic regression unless the wrappers also gain json tags.Relevant experts
kubernetes-specialist— controller-runtime conventions,runtime.RawExtensionround-trip semantics, registry-based deserializationplatform-engineer— sidecar client API surface, breaking-change strategy for the seictl moduleProposed approach
Phased migration; can be done incrementally per task type.
Phase 1 — sidecar client surface
For each of the 14 task types:
client.*Tasktyped wrapper exists. Add one if it doesn't (e.g.,ConfigPatch,ConfigureStateSyncalready exist; verify the rest).json:"camelCase"tags to the typed wrapper fields so directjson.Marshalproduces the same wire format the existingtoRequestParams()produces. DropTaskMeta.IDfrom JSON output viajson:"-"since it's controller-internal metadata, not wire payload.Phase 2 — controller migration
internal/task/sidecar.go) to acceptclient.TaskBuilderdirectly, callingToTaskRequest()to build thesidecar.TaskRequest. OverrideIdwith the deterministic task ID at submit time.*Paramsshell.client.*Task{...}directly.Deserializeregistry to unmarshal into the typed wrapper.taskParamserinterface and the compile-time assertion list once all 14 are migrated.Phase 3 (optional) — drop the JSON round-trip
Today: planner builds typed struct →
json.Marshal→PlannedTask.Params.Raw→json.Unmarshalback into typed struct →toRequestParams()→ JSON-marshal the map → wire bytes. Two round-trips for the same payload.Cleaner: planner builds typed wrapper →
ToTaskRequest()→ store theParamsmap directly inPlannedTask.Params.Raw→ executor reads bytes and submits verbatim. One round-trip.Worth doing only if Phase 1+2 reveal it's cheap — the executor's current
e.params Tshape is generic, so a refactor to "store wire bytes" needs care.Architectural constraints
TaskMeta.IDfield on every typed wrapper must serialize asjson:"-"(or be unexported) so it doesn't pollutePlannedTask.Params.Raw.Params.Rawin the old shape need to deserialize cleanly. If field names change (capital ↔ lowercase), the deserialization will silently produce zero-valued fields. Migrating in two steps (add json tags first, then migrate consumers) avoids this.*Paramstypes is breaking for any tooling that imports them. None known today; verify before commit.Acceptance criteria
client.*Taskwrapper with json tags.taskParamserinterface and the compile-time assertion list atinternal/task/sidecar.go:22-37are removed.client.TaskBuilder.kubectl get seinodedeploymentplanstorage format (operator-visible).Out of scope
seictl/sidecar/clientbeyond the existing 14.CreateExporter) — they don't go through the sidecar HTTP API and don't usetaskParamser.References
sei-protocol/sei-k8s-controller#160introduced the delegating-shell pattern (Phase 0): controller'sAssembleAndUploadGenesisParamsandAssembleForkGenesisParamskeep their shell but delegateValidate()andtoRequestParams()toclient.AssembleAndUploadGenesisTask/client.AssembleGenesisForkTask. This issue carries the migration to its logical conclusion.sei-protocol/seictl#121added the typed wrappers + strict bech32 validation for the assemble-genesis tasks.internal/task/sidecar.go:13-37—taskParamserinterface + compile-time assertions for the 14 implementing types.internal/task/sidecar.go:73-87— executor's current dispatch site that would shift toTaskBuilder.internal/task/task.go:248-262—deserializeSidecar[T]generic helper that hydratese.params TfromParams.Raw.🤖 Generated with Claude Code