Skip to content

Commit d743232

Browse files
dmealingclaude
andcommitted
Merge: Open-Closed typed nodes — attr/field value behavior on the class
Materializes attributes as MetaAttr instances (inline = parse-only), moves dataType/coerce/validateValue/desugar onto MetaAttr/MetaField with FilterAttr/StringArrayAttr/PropertiesAttr subclasses, and deletes the central datatype maps / converter object-arm / validator subtype-sets / parser desugar. A new attr/field subtype is now one class + one registration line. Serializer always-inline (D5); C# mirrored for corpus parity; Java verify-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 894a49d + 1a8301a commit d743232

24 files changed

Lines changed: 3028 additions & 656 deletions

docs/superpowers/plans/2026-05-22-typed-node-open-closed.md

Lines changed: 1746 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# Design: Open-Closed typed nodes — attribute & field value behavior on the class
2+
3+
**Date:** 2026-05-22
4+
**Status:** Approved (design)
5+
**Author:** Doug Mealing (with Claude)
6+
**Branch:** `feat/typed-node-open-closed` (off `main` @ `1cfdfbf`, which includes the attr.filter/properties feature)
7+
8+
## Problem
9+
10+
Adding a new value-shaped attribute subtype to the TS reference is shotgun surgery across central
11+
files. Measured on the just-shipped `attr.filter` + `attr.properties` feature: **TS = 11 source
12+
files, C# = 9 (mirrored TS), Java = 3** (one self-registering `FilterAttribute` + a registration
13+
line + a serializer branch). Java stayed cheap because it kept the **polymorphic, self-registering
14+
`MetaAttribute<T>`** pattern; the TS rewrite replaced it with **a single `MetaAttr`/`MetaField`
15+
class dispatched through central tables**:
16+
17+
- value type → the closed `AttrValue` union (`meta/meta-data.ts`)
18+
- value coercion → the `convertToDataType` switch (`data-converter.ts`)
19+
- normalization/desugar → the central parser (`parser-core.ts`)
20+
- value validation → hardcoded subtype-sets in `attr-schema-validate.ts`
21+
- subtype → datatype → the `ATTR_DATA_TYPE` / `FIELD_DATA_TYPE` maps (`core-types.ts`)
22+
23+
This is the expression problem: TS optimized for adding new *operations* cheaply at the cost of
24+
adding new *types* — the opposite of what a metamodel framework wants. It is systemic (`MetaField`
25+
has the same shape) and it multiplies across every language port.
26+
27+
**Root cause, deeper than the central switches:** in TS, inline `@`-attributes are stored as raw
28+
values in the owning node's flat `_attrs: Map<string, AttrValue>` and are **never materialized as
29+
`MetaAttr` instances** (only the rarer child-form `{ "attr.*": {...} }` creates instances). So there
30+
is no per-attribute object to carry behavior. In Java, the canonical parser materializes **every**
31+
attribute — inline included — as a `MetaAttribute` instance and dispatches `setValueAsString`
32+
polymorphically. "Inline vs child" is a **serialization concern owned by the parser**, not a property
33+
of the constructed model. Baking it into the TS model was the original mistake.
34+
35+
## Goal
36+
37+
Restore Open-Closed for typed nodes: **adding an attribute or field subtype is one class + one
38+
registration line, with zero edits to any central file.** The model represents every attribute
39+
uniformly as a `MetaAttr` instance; the parser is the sole owner of inline-vs-child syntax. TS
40+
converges structurally onto Java (and what C# mirrors), so future metamodel features cost ~1 class
41+
per language instead of ~9–11 central edits.
42+
43+
## Decisions
44+
45+
- **D1 — Scope: attributes AND fields.** Both use the single-class + central-dispatch pattern; fix
46+
both. (Fields are already instances, so their share is mostly moving datatype/value behavior onto
47+
the class.)
48+
- **D2 — Full materialization.** Attributes become `MetaAttr` instances in the model. Inline
49+
`@`-syntax is parse-time sugar only. No flat raw-value attr map in the constructed model.
50+
- **D3 — Behavior on the class, polymorphic.** `MetaAttr`/`MetaField` own `dataType` / `coerce` /
51+
`validateValue` / `desugar`, resolving by subtype; subclasses override (`FilterAttr`,
52+
`StringArrayAttr`, `PropertiesAttr`). The registry `factory` maps each `(type, subType)` to its
53+
class. Parser and validator dispatch to the instance.
54+
- **D4 — Keep the value-accessor API; reimplement over instances.** `ownAttr`/`attr`/`attrs`/
55+
`ownAttrs`/`hasAttr`/`setAttr` stay (they are the correct convenience layer, same as Java's
56+
`MetaData`), reimplemented to read/write through `MetaAttr` instances. Add `ownMetaAttr(name)` /
57+
`ownMetaAttrs()` for callers needing the instance.
58+
- **D5 — Canonical rule: attributes always emit inline `@name`.** Attributes have no children, so
59+
there is one canonical output form. No provenance flag on the model. The single child-form fixture
60+
in the corpus (`attr-properties-basic`) regenerates to inline; all other canonical output stays
61+
byte-identical.
62+
- **D6 — Straight to the end state. No backwards compatibility.** Delete the `_attrs` raw map and the
63+
legacy `_effectiveAttrs` raw-map merge outright. No delegation shims, no dual-path, no compat flags.
64+
(Per CLAUDE.md "No backwards-compat hacks.")
65+
- **D7 — Consumers.** No external TS adopters except downstream-consumer; fix it after the change. Internal
66+
monorepo consumers keep working through the reimplemented value accessors; update any that break.
67+
68+
## Design
69+
70+
### 1. Model & storage (`meta/meta-data.ts`)
71+
72+
Replace `private _attrs: Map<string, AttrValue>` with a name-indexed collection of `MetaAttr`
73+
instances (e.g. `private _attrNodes: Map<string, MetaAttr>`, insertion-ordered). Attributes are owned
74+
nodes (parented), not flat values.
75+
76+
- `setAttr(name, value)`: resolve the attr's class via the registry (declared subtype) or infer it
77+
via the existing `inferAttrSubType` (undeclared); create/update the `MetaAttr` instance; the
78+
instance coerces/validates its own value.
79+
- `ownAttr(name)``this._attrNodes.get(name)?.value`.
80+
- `attrs()` / `ownAttrs()` → value maps built from instances (effective resolution walks the super
81+
chain collecting instances by name, own wins). The legacy `_effectiveAttrs` raw-map implementation
82+
is deleted and replaced by instance-based effective resolution.
83+
- New: `ownMetaAttr(name): MetaAttr | undefined`, `ownMetaAttrs(): readonly MetaAttr[]`.
84+
85+
### 2. Behavior hierarchy
86+
87+
`MetaAttr` (base) gains:
88+
- `get dataType(): DataType` — resolves by `this.subType` (replaces `ATTR_DATA_TYPE`).
89+
- `coerce(raw: unknown): AttrValue` — replaces the `convertToDataType` body for the base subtypes.
90+
- `validateValue(value: AttrValue): ValueError[]` — replaces `valueMatchesType` + the subtype-sets.
91+
- `desugar(value: AttrValue): AttrValue` — default identity.
92+
93+
Subclasses override only what differs:
94+
- `FilterAttr` — object validation + the filter desugar (scalar→eq, array→in, null→isNull, or/and
95+
recurse), moved out of `parser-core.ts`.
96+
- `StringArrayAttr` — bare-string→one-element-array coercion, moved out of `normalizeStringArrayAttr`.
97+
- `PropertiesAttr` — object/string-bag validation.
98+
99+
`MetaField` (base) gains `get dataType()` (replaces `FIELD_DATA_TYPE`) and any field value behavior;
100+
subclass only where a field's *value* handling diverges (none required today — `currency` is a
101+
`long` carrying extra metadata attrs, not a distinct value shape).
102+
103+
The registry `TypeDefinition.factory` already creates a node per `(type, subType)`; register the
104+
specific class for each subtype so the right class is instantiated.
105+
106+
### 3. What collapses centrally (deleted)
107+
108+
- `ATTR_DATA_TYPE`, `FIELD_DATA_TYPE` maps and `dataTypeFor` (`core-types.ts`).
109+
- `convertToDataType` datatype switch (`data-converter.ts`) — folded into per-class `coerce`. (The
110+
`toAttrValue` undeclared-value path likewise becomes the base/inferred class's `coerce`.)
111+
- `valueMatchesType` + `STRING/NUMERIC/OBJECT_ATTR_SUBTYPES` sets (`attr-schema-validate.ts`) —
112+
folded into per-class `validateValue`. The attr-schema pass iterates materialized instances and
113+
calls `validateValue`.
114+
- `normalizeStringArrayAttr`, `normalizeFilterAttr` (`parser-core.ts`) — folded into the relevant
115+
subclasses.
116+
117+
### 4. What stays central (correctly)
118+
119+
Cross-node loader passes — e.g. `validateDataGridFilterValues` (a `@filter` references *sibling*
120+
filterable fields) — cannot live on a single node; they remain loader passes. This is the only
121+
legitimately-central category.
122+
123+
### 5. Parser & serializer
124+
125+
- **Parser** (`parser-core.ts`) becomes the sole owner of inline-vs-child syntax: inline `@`-keys and
126+
child-form `attr.*` both materialize into `MetaAttr` instances (right class via registry / inferred
127+
subtype), and the instance coerces+desugars its own value. The central coercion/normalization code
128+
is gone.
129+
- **Serializer** (`serializer-json.ts`): canonical rule D5 — attributes always emit inline `@name`
130+
(attrs have no children). Remove the child-node attr emission path from canonical output. Reading is
131+
from instances via `ownMetaAttrs()`.
132+
- **Overlay/merge + super chain**: merge `MetaAttr` instances by name (own/last-writer wins) — same
133+
semantics as the current raw-map merge, now over instances.
134+
135+
### 6. Cross-language
136+
137+
Java is already fully instance-based; C# mirrors it. This makes TS structurally identical. The
138+
conformance corpus is the guardrail: canonical output stays byte-identical except `attr-properties-basic`,
139+
which regenerates from child-form to inline `@config` (a deliberate, deterministic canonical change),
140+
regenerated for all languages.
141+
142+
### 7. Open-Closed proof (executable definition of done)
143+
144+
A test registers a throwaway `attr.fizz` + `field.fizz` subtype via **only** a new class + a single
145+
registration line, then asserts load → coerce → validate → canonical-serialize all work, with an
146+
assertion that no central file (datatype map, converter switch, validator set, value union) was
147+
touched. This is the regression guard for the property the refactor exists to create.
148+
149+
## Migration (direct to end state)
150+
151+
No incremental scaffolding, no delegation shims, no compat. Ordered for reviewable commits, but each
152+
lands real end-state code:
153+
154+
1. Add the behavior methods (`dataType`/`coerce`/`validateValue`/`desugar`) to `MetaAttr`/`MetaField`
155+
bases + the `FilterAttr`/`StringArrayAttr`/`PropertiesAttr` subclasses; register classes in the
156+
factory.
157+
2. Convert `MetaData` storage to `MetaAttr` instances; reimplement `setAttr`/`ownAttr`/`attrs`/etc.
158+
over instances; add `ownMetaAttr`; delete `_attrs` raw map and legacy `_effectiveAttrs`.
159+
3. Rewrite the parser to materialize all attrs into instances; delete the central
160+
coercion/normalization calls.
161+
4. Rewrite the attr-schema pass to dispatch `validateValue`; delete `valueMatchesType` + subtype-sets.
162+
5. Update the serializer to the always-inline canonical rule; delete central datatype maps.
163+
6. Regenerate `attr-properties-basic` expected output (TS + C#); fix any internal monorepo consumers;
164+
fix downstream-consumer.
165+
166+
**Verification (not optional):** full TS suite + whole-monorepo typecheck green; conformance
167+
byte-identical except the one regenerated fixture; C# `dotnet test` + Java metadata tests green; the
168+
Open-Closed proof test passes.
169+
170+
## Out of scope
171+
172+
- An instance-first public API migration beyond keeping the value accessors (they are the intended
173+
interface). `ownMetaAttr` is added but consumers aren't forced onto it.
174+
- Codegen/runtime behavior changes — unaffected via the value accessors.
175+
- Python.
176+
- Promoting the per-subtype-class pattern to other node kinds beyond attr/field (identity, relationship,
177+
origin, layout) — those don't carry coerced scalar values; revisit only if needed.
178+
179+
## Risks
180+
181+
- **Blast radius in core `MetaData`.** Storage + accessor rewrite is central. Mitigation: the value
182+
accessors keep their signatures; the test suite + byte-identical conformance are the guardrail.
183+
- **Effective-attr / merge semantics drift.** Instance-based super-chain + overlay merge must match
184+
the current last-writer-wins behavior exactly. Mitigation: existing extends/overlay tests + the
185+
conformance corpus.
186+
- **Canonical output drift.** Only `attr-properties-basic` should change. Mitigation: diff the full
187+
corpus canonical output; any other change is a bug.

fixtures/conformance/attr-properties-basic/expected.json

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
{
66
"object.entity": {
77
"name": "Subscriber",
8+
"@config": {
9+
"owner": "growth",
10+
"tier": "gold"
11+
},
812
"children": [
913
{
1014
"field.long": {
@@ -17,15 +21,6 @@
1721
"id"
1822
]
1923
}
20-
},
21-
{
22-
"attr.properties": {
23-
"name": "config",
24-
"value": {
25-
"owner": "growth",
26-
"tier": "gold"
27-
}
28-
}
2924
}
3025
]
3126
}

server/csharp/MetaObjects/SerializerJson.cs

Lines changed: 14 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ namespace MetaObjects;
1919
/// </summary>
2020
public static class SerializerJson
2121
{
22-
// Java int range — used for distinguishing int vs long attr subtypes.
23-
private const long JavaIntMax = 2_147_483_647L;
24-
private const long JavaIntMin = -2_147_483_648L;
25-
2622
// ---------------------------------------------------------------------------
2723
// Public API
2824
// ---------------------------------------------------------------------------
@@ -34,7 +30,7 @@ public static class SerializerJson
3430
/// </summary>
3531
public static string CanonicalSerialize(MetaData model)
3632
{
37-
var nodeObj = SerializeNode(model, inlineAttrs: true, effective: false);
33+
var nodeObj = SerializeNode(model, effective: false);
3834
var sorted = SortAttrKeys(nodeObj)!;
3935
return Stringify(sorted) + "\n";
4036
}
@@ -47,7 +43,7 @@ public static string CanonicalSerialize(MetaData model)
4743
/// </summary>
4844
public static string CanonicalSerializeEffective(MetaData model)
4945
{
50-
var nodeObj = SerializeNode(model, inlineAttrs: true, effective: true);
46+
var nodeObj = SerializeNode(model, effective: true);
5147
var sorted = SortAttrKeys(nodeObj)!;
5248
return Stringify(sorted) + "\n";
5349
}
@@ -68,28 +64,6 @@ private static string Stringify(JsonNode node)
6864
return node.ToJsonString(JsonOpts);
6965
}
7066

71-
// ---------------------------------------------------------------------------
72-
// Infer attr subType from a runtime AttrValue (for child-node form)
73-
// ---------------------------------------------------------------------------
74-
75-
private static string InferAttrSubType(object? value)
76-
{
77-
return value switch
78-
{
79-
IReadOnlyList<string> => Constants.ATTR_SUBTYPE_STRINGARRAY,
80-
bool => Constants.ATTR_SUBTYPE_BOOLEAN,
81-
long l => (l >= JavaIntMin && l <= JavaIntMax)
82-
? Constants.ATTR_SUBTYPE_INT
83-
: Constants.ATTR_SUBTYPE_LONG,
84-
double d => Number.IsIntegerDouble(d)
85-
? ((long)d >= JavaIntMin && (long)d <= JavaIntMax
86-
? Constants.ATTR_SUBTYPE_INT
87-
: Constants.ATTR_SUBTYPE_LONG)
88-
: Constants.ATTR_SUBTYPE_DOUBLE,
89-
_ => Constants.ATTR_SUBTYPE_STRING,
90-
};
91-
}
92-
9367
// ---------------------------------------------------------------------------
9468
// Build the fused "type.subType" wrapper key
9569
// ---------------------------------------------------------------------------
@@ -101,15 +75,15 @@ private static string FusedKey(string type, string subType)
10175
// Serialize a single node → { "<type>.<subType>": { ...body } }
10276
// ---------------------------------------------------------------------------
10377

104-
private static JsonObject SerializeNode(MetaData model, bool inlineAttrs, bool effective)
78+
private static JsonObject SerializeNode(MetaData model, bool effective)
10579
{
106-
var inner = SerializeNodeInner(model, inlineAttrs, effective);
80+
var inner = SerializeNodeInner(model, effective);
10781
var wrapper = new JsonObject();
10882
wrapper.Add(FusedKey(model.Type, model.SubType), inner);
10983
return wrapper;
11084
}
11185

112-
private static JsonObject SerializeNodeInner(MetaData model, bool inlineAttrs, bool effective)
86+
private static JsonObject SerializeNodeInner(MetaData model, bool effective)
11387
{
11488
// Canonical body-key order:
11589
// 1. name 2. package 3. extends 4. abstract
@@ -150,58 +124,23 @@ private static JsonObject SerializeNodeInner(MetaData model, bool inlineAttrs, b
150124
var childList = effective ? model.Children() : model.OwnChildren();
151125
var attrMap = effective ? model.Attrs() : model.OwnAttrs();
152126

153-
// Walk children: structural children recurse; attr children emit either as
154-
// inline @-attrs or as child {"attr.*": {...}} nodes.
155-
var emittedAsChild = new HashSet<string>(StringComparer.Ordinal);
127+
// Walk children: structural children recurse. Attrs are ALWAYS emitted
128+
// inline as @-attrs (never as child {"attr.*": {...}} nodes), so attr-typed
129+
// children are skipped here — the C# parser dual-stores each attr both as a
130+
// child node and as a parent attr (Parser.cs:950-951), and the inline loop
131+
// below emits it from attrMap.
156132
var serializedChildren = new JsonArray();
157133

158134
foreach (var child in childList)
159135
{
160-
if (child.Type != Constants.TYPE_ATTR)
161-
{
162-
serializedChildren.Add(SerializeNode(child, inlineAttrs, effective));
163-
continue;
164-
}
165-
166-
// Emit attr as child node form
167-
var attrName = child.Name;
168-
var attrValue = child.OwnAttr(Constants.RESERVED_KEY_VALUE);
169-
var attrSubType = child.SubType != Constants.SUBTYPE_BASE
170-
? child.SubType
171-
: InferAttrSubType(attrValue ?? "");
172-
173-
var attrBody = new JsonObject
174-
{
175-
{ Constants.RESERVED_KEY_NAME, JsonValue.Create(attrName) },
176-
{ Constants.RESERVED_KEY_VALUE, AttrValueToJsonNode(attrValue) },
177-
};
178-
var attrWrapper = new JsonObject();
179-
attrWrapper.Add(FusedKey(Constants.TYPE_ATTR, attrSubType), attrBody);
180-
serializedChildren.Add(attrWrapper);
181-
emittedAsChild.Add(attrName);
136+
if (child.Type == Constants.TYPE_ATTR) continue;
137+
serializedChildren.Add(SerializeNode(child, effective));
182138
}
183139

184-
// Inline @-attrs: emit attrs NOT already emitted as child nodes.
140+
// Inline @-attrs — always emitted as @name; attrs have no child form.
185141
foreach (var (attrName, attrValue) in attrMap)
186142
{
187-
if (emittedAsChild.Contains(attrName)) continue;
188-
189-
if (inlineAttrs)
190-
{
191-
obj.Add($"{Constants.ATTR_PREFIX}{attrName}", AttrValueToJsonNode(attrValue));
192-
}
193-
else
194-
{
195-
var subType = InferAttrSubType(attrValue);
196-
var attrBody = new JsonObject
197-
{
198-
{ Constants.RESERVED_KEY_NAME, JsonValue.Create(attrName) },
199-
{ Constants.RESERVED_KEY_VALUE, AttrValueToJsonNode(attrValue) },
200-
};
201-
var attrWrapper = new JsonObject();
202-
attrWrapper.Add(FusedKey(Constants.TYPE_ATTR, subType), attrBody);
203-
serializedChildren.Add(attrWrapper);
204-
}
143+
obj.Add($"{Constants.ATTR_PREFIX}{attrName}", AttrValueToJsonNode(attrValue));
205144
}
206145

207146
// children — emit only if non-empty

0 commit comments

Comments
 (0)