Standalone core ticket, referenced by #18605 (OQ9 in D#18594). Decision Record impact: none.
unowned-rationale: filed from the design seat at graduation; claimable by self-selection.
Context
Measured in D#18594's receipt (ShadowWorkspace arm at dev@28e56e1543): a subclass of Neo.dashboard.dock.Workspace declaring dockModel_: null — where the base declares the class field dockModel = null (Workspace.mjs:411) — never runs afterSetDockModel. Nothing warns.
The Problem
The rule (maintainer, 2026-09-12): a class extension never replaces a class field with a config, or a config with a field. A field and a reactive config are different kinds of member — an own data property initialized by new, and a prototype accessor generated by Neo.setupClass — and a name shared across the two in one prototype chain is not an override, it is a collision. The rule is documented nowhere and enforced nowhere: Neo.createConfig (src/Neo.mjs:346) refuses a redefined setter at setup time, but a field on a base class (or a field over an inherited config) passes silently. What the violation does, measured on unmodified dev@8cab26fcf8: the own data property shadows the accessor for the instance's lifetime, processConfigs skips the initial assignment because the instance already owns the key, and every later write lands in the field — the config's default never applies, beforeSet / afterSet never run, observeConfig never fires, and nothing reports it (the control class with the config alone reads 1 with the hook fired; the shadowed one reads null with the hook never fired, through set(), direct assignment and an observer alike). The origin was a Neo.dashboard.dock.Workspace subclass declaring dockModel_ over the base's dockModel = null field: afterSetDockModel never ran, and nothing said why.
The operator chose enforcement by throwing, analogous to the duplicate-trailing-underscore refusal. The author adopted the after-new/before-construct boundary in the convergence disposition. The guide documents the same rule alongside inherited reactive config overrides.
The Fix
Call core.Base#assertFieldsShadowNoConfig from Neo.create immediately after new cls() and before construct(config). Refuse any own data property whose name is an own key of the merged static config, for plain and reactive configs alike. Do not cache the result by class. At this point native construction is complete and subclass construct() pre-processing has not run. Ordinary assignments inside construct() therefore remain valid; deleting a conflicting field there is too late to legalize its declaration. The diagnostic describes the observable instance after native construction; it does not infer the field's declaring class.
Contract Ledger
| Target surface |
Source of authority |
Refuses |
Accepts |
Docs |
Evidence |
Neo.create after new cls(), before construct(config), calling core.Base#assertFieldsShadowNoConfig |
operator's fixed-category rule; author disposition IC_kwDODSospM8AAAABULU4AQ; own keys of merged static config |
own data property overlapping a class config: field → plain/reactive config and plain/reactive config → field, including same-class conflicts; throws before construct() can remove the conflict |
field → field value overrides; plain config defaults; inherited reactive defaults using the bare key; own accessors; non-config fields over ordinary accessors; construct-time assignments; fields whose names are absent from the class's own config map |
guard and static-config docblocks; ExtendingNeoClasses §2 |
15 core tests, exact-head four-crossing and valid-control probes, current-head unit CI |
Acceptance Criteria
Out of Scope
Converting any existing field to a config.
Related
#18605 · D#18594 (OQ9).
Filed at the graduation of D#18594; live latest-open sweep at 2026-09-12T13:55:27Z found no equivalent.
Origin Session ID: c42870ab-3721-40f0-a1f8-f385786ffc3c
Retrieval Hint: "class field shadows subclass reactive config createConfig setter guard own data property"
🪢 Mnemosyne (Claude Fable 5.1, Claude Code) · session c42870ab-3721-40f0-a1f8-f385786ffc3c
Standalone core ticket, referenced by #18605 (OQ9 in D#18594).
Decision Record impact: none.unowned-rationale:filed from the design seat at graduation; claimable by self-selection.Context
Measured in D#18594's receipt (
ShadowWorkspacearm atdev@28e56e1543): a subclass ofNeo.dashboard.dock.WorkspacedeclaringdockModel_: null— where the base declares the class fielddockModel = null(Workspace.mjs:411) — never runsafterSetDockModel. Nothing warns.The Problem
The rule (maintainer, 2026-09-12): a class extension never replaces a class field with a config, or a config with a field. A field and a reactive config are different kinds of member — an own data property initialized by
new, and a prototype accessor generated byNeo.setupClass— and a name shared across the two in one prototype chain is not an override, it is a collision. The rule is documented nowhere and enforced nowhere:Neo.createConfig(src/Neo.mjs:346) refuses a redefined setter at setup time, but a field on a base class (or a field over an inherited config) passes silently. What the violation does, measured on unmodifieddev@8cab26fcf8: the own data property shadows the accessor for the instance's lifetime,processConfigsskips the initial assignment because the instance already owns the key, and every later write lands in the field — the config's default never applies,beforeSet/afterSetnever run,observeConfignever fires, and nothing reports it (the control class with the config alone reads1with the hook fired; the shadowed one readsnullwith the hook never fired, throughset(), direct assignment and an observer alike). The origin was aNeo.dashboard.dock.Workspacesubclass declaringdockModel_over the base'sdockModel = nullfield:afterSetDockModelnever ran, and nothing said why.The operator chose enforcement by throwing, analogous to the duplicate-trailing-underscore refusal. The author adopted the after-new/before-construct boundary in the convergence disposition. The guide documents the same rule alongside inherited reactive config overrides.
The Fix
Call
core.Base#assertFieldsShadowNoConfigfromNeo.createimmediately afternew cls()and beforeconstruct(config). Refuse any own data property whose name is an own key of the merged static config, for plain and reactive configs alike. Do not cache the result by class. At this point native construction is complete and subclass construct() pre-processing has not run. Ordinary assignments inside construct() therefore remain valid; deleting a conflicting field there is too late to legalize its declaration. The diagnostic describes the observable instance after native construction; it does not infer the field's declaring class.Contract Ledger
Neo.createafternew cls(), beforeconstruct(config), callingcore.Base#assertFieldsShadowNoConfigIC_kwDODSospM8AAAABULU4AQ; own keys of merged static configAcceptance Criteria
static config(maintainer ask, 2026-09-13: an edge case on a hot path — creating many instances must not suffer).Out of Scope
Converting any existing field to a config.
Related
#18605 · D#18594 (OQ9).
Filed at the graduation of D#18594; live latest-open sweep at 2026-09-12T13:55:27Z found no equivalent.
Origin Session ID: c42870ab-3721-40f0-a1f8-f385786ffc3c Retrieval Hint: "class field shadows subclass reactive config createConfig setter guard own data property"
🪢 Mnemosyne (Claude Fable 5.1, Claude Code) · session c42870ab-3721-40f0-a1f8-f385786ffc3c