FIX-020 «Every option type carries a dead config-type assertion (with reflection) in its Apply method»¶
Type: FIX (one-shot bug-fix; not rewritten after landing).
Status: Accepted (2026-07-04, branch test/harden-core-coverage, landed 8aacd72).
Date: 2026-07-04.
Author: dr-dobermann.
Branch: test/harden-core-coverage (folded into the in-flight core-hardening work; surfaced during FIX-019 while making error paths reflection-free).
Paired doc: none (local to the options mechanism across pkg/model).
Upstream: self-contained per hierarchy-rule (no ADR/SAD governs the options utility).
Grounded in (internal artifacts):
- pkg/model/options/option.go (the Option/Configurator interfaces).
- The 19 option-type files + ~18 dispatching constructors (inventory in §1–§2).
- Reference implementations already hardened: pkg/model/activities/receive_task.go:71, send_task.go:52.
§1 Symptoms¶
A design/dead-code defect, the sibling of FIX-019. options.Option is
interface { Apply(cfg Configurator) error }. 21 option types across 7
packages are func types (func(*Xconfig) error) that implement Apply by
type-asserting the generic Configurator back to their concrete config and,
on failure, returning an error carrying reflect.TypeOf(cfg).String():
// pkg/model/activities/activity_options.go:211
func (ao ActivityOption) Apply(cfg options.Configurator) error {
if ac, ok := cfg.(*activityConfig); ok {
return ao(ac)
}
return errs.New(errs.M("cfg isn't an activityConfig"),
errs.C(errorClass, errs.InvalidParameter, errs.TypeCastingError),
errs.D("cfg_type", reflect.TypeOf(cfg).String())) // dead + reflective
}
Three consequences:
§1.1 The assertion branch is unreachable dead code¶
Every constructor that dispatches these options already type-switches to
identify the option and passes it the matching config (e.g.
newActivity calls o.Apply(&cfg) with &cfg always *activityConfig). So
cfg.(*activityConfig) always succeeds; the else never runs. All 21 rows in
the §2 inventory are dead. Proof: NewReceiveTask/NewSendTask
(receive_task.go:71, send_task.go:52) already bypass Apply and call the
option func directly — their Apply methods are already orphaned.
§1.2 Runtime reflection in the option layer¶
The dead branches carry reflect.TypeOf(cfg).String() (19 of the 21). FIX-019
removed reflection from errs; this is the reflection those error paths still
reference on the option-construction path. (Two — ComplexOption,
EventBasedOption — have the dead assertion but no reflect.)
§1.3 Redundant indirection¶
Apply(Configurator) erases the concrete config type only to re-assert it
one call later — a round trip that exists solely to satisfy a single interface
method the constructors don't actually need (they've already narrowed the type
in their switch).
Impact: 21 dead branches, 19 reflective, and a redundant interface method
whose only real polymorphic consumer (foundation.NewBaseElement) can be a
type-switch too.
§2 Root Cause Analysis¶
§2.1 The erased-type round trip¶
options.Option.Apply(cfg Configurator) is the sole unifier that lets
New(name string, ...options.Option) take a heterogeneous option bag. But
Configurator erases the concrete config type, so each option must re-assert
it — and that re-assertion is the dead branch. The constructors, however,
already switch opt.(type) to route options; once the switch has matched
case ActivityOption, the concrete option type is known and its func can be
called with the concrete config directly. The Apply indirection is redundant
with the switch the constructors already perform.
§2.2 Inventory — the dead Apply-assertion sites (category (a))¶
| Option type | Type def | Apply |
reflect(cfg) | Underlying func |
|---|---|---|---|---|
startOption |
events/start_options.go:16 | :56 | :62 | func(*startConfig) error |
endOption |
events/end_options.go:15 | :36 | :42 | func(*endConfig) error |
EventOption |
events/event_options.go:44 | :48 | :54 | func(eventConfig) error (iface) |
ActivityOption |
activities/activity_options.go:30 | :211 | :219 | func(*activityConfig) error |
taskOption |
activities/task_options.go:19 | :22 | :29 | func(*multyInstance) error |
RcvTaskOption |
activities/receive_task_options.go:26 | :29 | :39 | func(*rcvTaskConfig) (already bypassed) |
RoleOption |
activities/role_option.go:20 | :23 | :31 | func(RoleConfigurator) error (iface) |
SndTaskOption |
activities/send_task_options.go:25 | :28 | :38 | func(*sndTaskConfig) (already bypassed) |
UsrTaskOption |
activities/user_task_options.go:30 | :232 | :240 | func(*usrTaskConfig) error |
asscOption |
data/data_options.go:84 | :161 | :166 | func(*asscConfig) error |
PropertyOption |
data/property_option.go:21 | :72 | :80 | func(PropertyAdder) error (iface) |
itemOption |
data/item_options.go:18 | :74 | :80 | func(*itemConfig) error |
IAEOption |
data/item_options.go:104 | :169 | :174 | func(*iaeConfig) error |
IAEAdderOption |
data/item_options.go:207 | :238 | :246 | func(IAEAdder) error (iface) |
sflowOption |
flow/sequenceflow_options.go:20 | :23 | :31 | func(*sflowConfig) error |
NameOption |
options/name_option.go:19 | :22 | :30 | func(NameConfigurator) error (iface) |
GatewayOption |
gateways/gateway_options.go:20 | :60 | :68 | func(*gatewayConfig) error |
ComplexOption |
gateways/complex.go:125 | :128 | — | func(*complexConfig) error |
EventBasedOption |
gateways/event_based.go:63 | :66 | — | func(*eventBasedConfig) error |
ciOption |
hinteraction/consinp/consinp_options.go:22 | :187 | :195 | func(*consInpConfig) error |
BaseOption |
foundation/options.go:24 | :33 | :39 | func(*baseConfig) error |
The 5 interface-typed options (EventOption, RoleOption, PropertyOption,
IAEAdderOption, NameOption) still harden cleanly: the config the
constructor passes implements that interface, so a direct o(&cfg)
compiles — which is exactly why the assertion never fails.
§2.3 Reachable input-validation guards (category (b) — MUST STAY)¶
Distinct from (a): the constructor-level default: "invalid option type" arms
fire on genuine caller misuse (a wrong option passed to a constructor). They
reflect on o (the option), not cfg — the reliable (a)/(b) tell. These stay:
activities/activity.go:63, user_task.go:113; data/item.go:101,261,
association.go:105; flow/sequenceflow.go:111; gateways/gateway.go:128;
process/process.go:70; events/start.go:77, end.go:72;
consinp/consinp.go:94.
§2.4 The one polymorphic consumer¶
foundation.NewBaseElement (base.go:98) loops for _, o := range opts {
o.Apply(&bc) } with no switch — the only place Apply is real polymorphic
dispatch. It only ever receives BaseOption (every other constructor collects
just BaseOption into baseOpts before delegating here). It also currently
serves as the base-layer catch-all: options that leaked through a
constructor without a category-(b) guard reach here and are rejected when
BaseOption.Apply(*baseConfig) fails. Hardening it to a switch must add an
explicit default: to preserve that rejection (§6.1).
§3 Solution¶
Harden every constructor to call the option's concrete func directly, delete
all 21 Apply methods (removing every dead assertion + reflect), and
redefine options.Option as a marker interface so ...options.Option stays
type-safe without a generic Apply. Category-(b) validation is fully
preserved.
§3.1 Alternatives considered¶
| Alternative | Pros | Cons | Decision |
|---|---|---|---|
A. Harden dispatch only, keep Apply (Depth 1) |
no interface change; dead branches stop executing (no runtime reflection) | leaves 21 orphaned Apply methods — the exact dead-code smell being removed |
❌ rejected |
B. Keep Apply, only drop the reflect.TypeOf(cfg) detail |
minimal diff; reflection-free | keeps the dead assertion + redundant indirection | ❌ rejected: half-measure |
C. Full: harden dispatch + delete Apply + marker Option (Depth 2) |
removes all dead assertions, all option-layer reflection, and the redundant round trip; compile-time-safe option bag | wider mechanical churn (~21 files + interface + ~10 tests); marker must be exported (§3.1 note) | ✅ chosen |
Cross-package marker constraint (why the marker is exported): Go only
permits a type to implement an interface's unexported method within the
defining package. The 21 option types live in 7 packages, so a sealed
interface { isOption() } is impossible. The marker is therefore an
exported no-op method (Option()). It's not perfectly sealed (an external
type could implement it) but constrains ...options.Option to real option
types at compile time — which is the goal; sealing against out-of-tree
imposters is a non-concern for a gobpm-internal utility.
§3.2 Changes by file¶
§3.2.1 pkg/model/options/option.go — marker interface¶
// before:
type Option interface { Apply(cfg Configurator) error }
// after:
type Option interface { Option() } // marker; options apply via constructor dispatch
Assess Configurator (interface { Validate() error }): if still used by
config self-validation (cfg.Validate() in constructors) it stays; if only
Apply's signature referenced it, it's removed. (Determined at implementation
by grep; recorded in §8.)
§3.2.2 The 21 option types (§2.2 inventory) — delete Apply, add marker¶
Per type, mechanically: delete the Apply method (with its dead assertion +
reflect), add func (X) Option() {}, drop the now-unused reflect import.
Uniform transformation; the inventory table is the complete file list.
§3.2.3 The dispatching constructors — direct func dispatch¶
Split each multi-type switch case into per-type cases that call the option
func directly; convert single-case and concrete-assertion sites likewise.
Preserve every category-(b) default: guard.
- Multi-type:
eventsstart (start.go:73),eventsend (end.go:68),newActivity(activity.go:56),flownewSequenceFlow (sequenceflow.go:104),process.New(process.go:63),gateways.New(gateway.go:120). - Single-case:
newTask(task.go:36), user-task (user_task.go:105),NewItemDefinition(item.go:97),NewIAE(item.go:257),NewAssociation(association.go:98),NewRenderer(consinp.go:82). - Concrete-assertion:
newEvent(event.go:171),NewProp(property.go:105),NewComplexGateway(complex.go:210),NewEventBasedGateway(event_based.go:134).
Example (newActivity):
// before:
case ActivityOption, RoleOption, data.PropertyOption:
if err := o.Apply(&cfg); err != nil { ee = append(ee, err) }
// after:
case ActivityOption:
if err := o(&cfg); err != nil { ee = append(ee, err) }
case RoleOption: // *activityConfig implements RoleConfigurator
if err := o(&cfg); err != nil { ee = append(ee, err) }
case data.PropertyOption: // *activityConfig implements PropertyAdder
if err := o(&cfg); err != nil { ee = append(ee, err) }
§3.2.4 pkg/model/foundation/base.go — switch-harden NewBaseElement¶
Convert the polymorphic for … { o.Apply(&bc) } loop to
switch o := opt.(type) { case BaseOption: bo(&bc); default: <reject> }. The
new explicit default: replaces the base-layer catch-all that
BaseOption.Apply's dead assertion currently provided (§2.4, §6.1).
§3.2.5 Test call sites — update .Apply(...)¶
~10 sites call .Apply(...) directly: property_test.go:87-128,
options_test.go:51-64, base_test.go:60, complex_test.go:113,
event_based_test.go:229, and the receive/send internal tests. Update to
call the underlying func (or assert the marker). Enumerated exactly at
implementation.
§4 Verification¶
Coverage: this is a behavior-preserving refactor — the existing constructor tests are the regression net.
§4.1 Regression tests¶
§4.1.1 Behavior unchanged — existing constructor suites pass¶
Existing: every New* constructor test across pkg/model (activities,
events, data, gateways, flow, foundation, process, consinp). A valid option set
must build the same object; assertions on the built object are unchanged.
§4.1.2 Category-(b) misuse still rejected¶
Existing / augment: passing an option a constructor doesn't accept still
returns an "invalid option type" error (the (b) guards). Where a package lacks
such a test, add one (e.g. NewServiceTask("x", op, <a gateway option>) →
error).
§4.1.3 Compile-time safety of the marker¶
New("x", 42) (a non-option) must fail to compile under the marker
interface. Not a runtime test — noted as a property; verified by the
go build ./... gate (an accidental ...Option = ...any regression would
let non-options through).
§4.5 Observability¶
None — no behavior change; the win is dead-code + reflection removal, visible
in the diff and a grep -rn "reflect" pkg/model going quiet on the option
layer.
§5 Prevention¶
- Doc comment on
options.Option: it is a compile-time marker; options are applied by each constructor's type-switch calling the option's concrete func — there is no genericApply. New option types add the marker + their func; new constructors dispatch by direct func call (followNewReceiveTask/NewServiceTask), never reintroduce anApply(Configurator)round trip. - Canary: the category-(b) misuse tests (§4.1.2) + the
go buildmarker constraint (§4.1.3).
§6 Regressions / side-effects¶
§6.1 What may rely on the old behaviour¶
foundation.NewBaseElementbase-layer rejection — options that leaked past a guardless constructor were caught here byBaseOption.Apply's failure. The switch-hardening MUST add an explicitdefault:reject to preserve it (§3.2.4). Audit:grep -rn "NewBaseElement\|baseOpts" pkg/modelto confirm no path depends on the old silent-collect-then-reject flow.- Any external caller of
options.Option.Apply— grepgrep -rn "\.Apply(" pkg internalconfirmed all call sites are in-tree constructors/tests; deletingApplybreaks nothing outside those (updated in §3.2.3/§3.2.5). Configurator— if it becomes unused afterApplydeletion, remove it; if config self-validation still uses it, keep. Confirm pre-landing.
§6.2 Rollback path¶
Single-commit revert (or a small stack of stage commits). No data/migration.
§6.3 Cross-team backlog¶
None — pkg/model/options and its consumers are all in-repo.
§7 Related¶
- FIX-019 (string-typed reflection-free
errsdetails) — surfaced thesereflect.TypeOf(cfg)sites during its call-site migration; FIX-019 deliberately left them for this FIX. - Precedent: the
eventConfigfold (this session) — folded per-adder assertions into theeventConfiginterface soWithXxxTriggercallscfg.setXxxdirectly. Same "compile-enforce, delete the runtime assertion" move, one layer down. FIX-020 completes it at theoptions.Optionlayer. - Reference implementations:
NewReceiveTask/NewSendTaskalready use direct func dispatch — the target pattern. - Promote-to-ADR candidate: the invariant "model options apply via
constructor type-switch + direct func call;
options.Optionis a marker, not a generic applicator" could promote to a small ADR if a third options-related decision appears.
§8 Implementation summary (stage-by-stage actual landings + deltas vs draft)¶
§8.1 Stages by commit (branch test/harden-core-coverage)¶
| Stage | Commit | Scope | Tests |
|---|---|---|---|
| 1 | 8aacd72 |
marker Option() interface; deleted all 21 Apply methods; hardened ~18 constructors + NewBaseElement (switch + explicit reject); updated ~10 .Apply test call sites — landed atomically (the interface change ripples through all of it) |
constructor suites unchanged; net −179 lines |
| — | 8bbfa46 |
error-path tests (see §8.2) | instance/thresher/eventhub |
The 3 draft stages could not be separately-green: changing options.Option to
the marker breaks every constructor that still calls .Apply until they all
switch to direct dispatch, so it lands as one commit.
§8.2 Empirical findings — where reality diverged from the §3 draft¶
Configuratorkept. After deleting theApplysignatures,Configuratoris still referenced (embedded byNameConfigurator/RoleConfigurator/PropertyAdder/IAEAdder/eventConfig; each config'sValidate()is called by its constructor), so it stays.- All 5 interface-typed options compiled directly — no config failed to implement the required interface; the hardening had no exceptions.
errorClassorphaned inoptions. DeletingNameOption.Applyleftoptions.errorClassunused (golangci-lintunused); removed it. (No other package'serrorClasswas affected — all still used by real errors.)- Diff-coverage detour (
8bbfa46). The branch-wide diff-coverage gate (measured vsorigin/master, so it spans FIX-019+FIX-020) flagged 7 FIX-019 diagnostic-detail lines in error branches that were only flakily covered by integration tests (FIX-019's own gate got lucky). Added deterministic unit tests forinstance.Run/PropagateEventguards,thresher.PropagateEvent's hub-failure, andeventhub'sProcess/AddEventProcessorfailures → 96.8%.
§8.3 Backlog (out of FIX-020 scope)¶
- Category-(b) constructor guards still reflect on the misused option
(
reflect.TypeOf(o).String()); left in place (reachable input validation on a cold misuse path, not a runtime hot path). De-reflecting them is a possible follow-up.