Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 04 — The kernel M2: @Concept, @Property, @ChildLink, @ReferenceLink

The kernel from article 02 contains five concepts. The first one — the metamodel — is described by exactly four TypeScript decorators. This article specifies each of the four, names the options they take, walks one worked example end to end (the requirements DSL's Feature class), and shows where each decorator is consumed downstream by the micro-DSLs from Part III. The article is deliberately short on philosophy — articles 01–03 did the architectural work — and long on the surface every consumer touches.

The four decorators are calqued on the existing @Generate family from packages/ts-codegen-pipeline/src/attributes.ts. Calqued in two senses: they share the implementation pattern (TC39 stage-3 standard decorators, no experimentalDecorators, no reflect-metadata, pure metadata recorded in a module-load registry that ts-morph re-derives at extraction time without executing the module); and they share the naming convention (PascalCase decorator name, options object as the only argument, optional fields documented inline). A reader of ts-codegen-pipeline recognises the family at first glance.

@Concept({ id: 'cmf.req.Feature', abstract: false, extends: 'cmf.req.Requirement' })
export class Feature {
  // properties, child links, reference links go here
}

@Concept declares a node type the metamodel recognises. The id is the global identifier — fully qualified, dot-separated, namespaced by the DSL it belongs to (here, cmf.req for "CMF Requirements"). The abstract flag, defaulting to false, controls whether instances of the Concept can exist directly: an abstract Concept exists only as a parent for inheritance. The extends field, optional, names the parent Concept by id; the kernel's Structure Model walker resolves the string against the registered Concepts at extraction time and complains if the parent does not exist or is not a Concept.

The TypeScript class itself is the carrier. Its instance fields, decorated with @Property / @ChildLink / @ReferenceLink, declare the Concept's structure. Methods on the class are not part of the metamodel — they are convenience accessors the application code can use, but the kernel ignores them. The class can have a constructor, can be instantiated via the kernel's Concept factory, can be extended in TypeScript — but the truth about its structure is the decorator metadata, not the TypeScript shape.

Three derived facts matter for downstream micro-DSLs. First, the Structure Model exposes a typed Concepts map indexed by id, so any micro-DSL can ask what is the Concept with id cmf.req.Feature? and receive the metadata. Second, the inheritance chain from extends is materialised — Feature.parent === Requirement is true at the Structure Model level — so the Symbols micro-DSL can walk the chain to compose breadcrumbs and the Diagnostics micro-DSL can inherit constraints from the parent. Third, abstract Concepts contribute their structure to subclasses but cannot be instantiated by the AST runtime — the kernel rejects new Concept('cmf.req.Requirement') because Requirement is abstract: true.

The MPS counterpart of @Concept is the Concept declaration in the Structure aspect; the difference is that we live in TypeScript and inherit class semantics for free, where MPS reinvents class semantics in its own M2 language. The TypeScript adoption is closer to the user's daily idiom and avoids the impedance mismatch between Structure-aspect Concepts and BaseLanguage classes that MPS authors learn to navigate.

class Feature {
  @Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
  id!: string;

  @Property({ type: 'string', i18n: true })
  title!: string;

  @Property({ type: 'enum', values: ['Low', 'Medium', 'High', 'Critical'] })
  priority!: 'Low' | 'Medium' | 'High' | 'Critical';

  @Property({ type: 'date', optional: true })
  scheduled?: Date;
}

@Property declares a typed primitive on a Concept. The type field is mandatory: 'string', 'number', 'boolean', 'date', 'enum', plus a small extension list ('uri', 'duration', 'markdown') the kernel recognises. The constraint field, optional, is a runtime validator: a regex for strings, a numeric range for numbers, a list of allowed values for enums, a custom predicate function for anything else. The optional flag controls whether the property must have a value at AST construction time. The i18n flag, optional, marks the property as user-localisable — consumed by the Hover micro-DSL (article 10) for content rendering and by the Generator micro-DSL (article 19) for resource bundle emission.

The constraint field is the load-bearing one for downstream. The Diagnostics micro-DSL (article 11) walks every @Property({ constraint }) declaration and auto-derives one validator per constraint, with default severity error. A Concept author who declares constraint: /^FEATURE-\d+$/ on Feature.id does not write a separate diagnostic — the diagnostic appears for free, with a sensible default message and a code action that proposes a fix when the regex is unsatisfied. This is the mass production angle of the architecture: a single declarative fact in the kernel produces work in three downstream micro-DSLs (Diagnostics for the validator, Completion for the suggestion of valid values, Hover for the constraint description on mouse-over).

The TypeScript type annotation (: string, : 'Low' | 'Medium' | ...) is redundant with @Property({ type }) from the kernel's perspective — the decorator is the truth — but it is necessary for TypeScript to type-check the consuming code. The kernel cross-checks the two at extraction time and complains if they disagree (a decorator saying type: 'string' on a field annotated : number is a static error caught by the Diagnostics micro-DSL's metaspec validation pass). The redundancy is the price of staying in idiomatic TypeScript instead of inventing a closed parser.

class Feature {
  @ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*', outlineLabel: 'AC' })
  acceptance!: AcceptanceCriterion[];

  @ChildLink({ target: 'cmf.req.Note', card: '0..*' })
  notes!: Note[];
}

@ChildLink declares aggregation: the parent Concept owns the children, the children's lifecycle is bound to the parent's, and serialising the parent serialises the children inline. The target field names the child Concept's id; the card field declares cardinality with a small grammar (0..1, 1, 0..*, 1..*, n..m). The outlineLabel field, optional, is a hint for the Symbols micro-DSL (article 16) on how to label the child group in the document outline.

The semantics are deliberately strong. A child of a @ChildLink cannot exist without its parent — the AST runtime forbids orphaned children, and removing the parent removes the children. Identity propagates: if the parent's NodeId changes (a structural rename), the children's NodeIds remain stable, but their canonical path through the AST changes. The Refactoring micro-DSL (article 17) relies on this for move operations: moving a Feature between files moves its AcceptanceCriterion children atomically, with one PatchBus operation.

The Symbols micro-DSL derives the document outline directly from the @ChildLink declarations on the workspace's Concepts. A Concept with three @ChildLink declarations contributes three outline groups; the outlineLabel field labels each group; the children populate the leaves. No additional declaration is needed in the Symbols micro-DSL; the outline is derived, not declared.

class Feature {
  @ReferenceLink({ target: 'cmf.req.Epic', card: '1' })
  epic!: EpicRef;

  @ReferenceLink({ target: 'cmf.req.Feature', card: '0..*' })
  blocks!: FeatureRef[];
}

@ReferenceLink declares association: the source Concept refers to the target, but does not own it. The target lives elsewhere in the AST (or even in another file in the workspace); deleting the target does not delete the source; the source carries a NodeId reference, not the target's content. The target field names the referenced Concept's id; the card field uses the same grammar as @ChildLink.

The contrast with @ChildLink is what makes the metamodel useful for Refactoring. A rename of a Feature (say, changing FEATURE-156 to FEATURE-156-revised) updates the Feature's declaration and every @ReferenceLink that points to it across the workspace, in one PatchBus transaction. A delete of a Feature either fails (because something still references it) or cascades through references with explicit user confirmation (the Refactoring micro-DSL surfaces a "this delete will break N references" code action). Without the distinction between aggregation and association, the kernel cannot distinguish the AC belongs to this Feature from the AC mentions this Feature — and Refactoring cannot offer the right behaviour.

The other downstream consumer is the Hover micro-DSL. When the cursor is on a reference token, Hover walks the kernel's @ReferenceLink registry to find which Concept the reference resolves to, queries the Structure Model for the canonical declaration, and renders the target's properties. Without @ReferenceLink, Hover would have to do its own ad-hoc resolution by string matching — the same anti-pattern the meta-ide-dsl prototype was honest enough to leave as // Proposal: resolve a FeatureId reference to its class declaration.

Combining all four decorators on one Concept:

@Concept({ id: 'cmf.req.Feature', abstract: false, extends: 'cmf.req.Requirement' })
export class Feature {
  @Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
  id!: string;

  @Property({ type: 'string', i18n: true })
  title!: string;

  @Property({ type: 'enum', values: ['Low', 'Medium', 'High', 'Critical'] })
  priority!: 'Low' | 'Medium' | 'High' | 'Critical';

  @Property({ type: 'date', optional: true })
  scheduled?: Date;

  @ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*', outlineLabel: 'AC' })
  acceptance!: AcceptanceCriterion[];

  @ChildLink({ target: 'cmf.req.Note', card: '0..*' })
  notes!: Note[];

  @ReferenceLink({ target: 'cmf.req.Epic', card: '1' })
  epic!: EpicRef;

  @ReferenceLink({ target: 'cmf.req.Feature', card: '0..*' })
  blocks!: FeatureRef[];
}

What this declaration enables, at the suite level:

  • Syntax (article 07) — token recognisers for the literals: FEATURE-156 matches the id constraint regex, Priority.Critical matches the enum form, decorator names like @Feature are picked up.
  • Completion (article 08) — after typing priority: ', the four enum values appear; after typing epic: , every Epic Concept's id appears.
  • Snippets (article 09) — a feat snippet expands into the full Feature skeleton with placeholders aligned to the declared properties.
  • Hover (article 10) — hovering on a FeatureRef resolves the reference, walks to the target Feature's declaration, renders title + priority + AC count.
  • Diagnostics (article 11) — the regex constraint generates a validator that flags any id not matching FEATURE-\d+, with a code action that suggests the next free id.
  • CodeLens (article 12) — anchors on the Feature declaration line: 2 implementations, 5 satisfying tests, 1 blocked by.
  • Views (article 14) — the workspace tree shows Epic > Feature > AC based on the @ChildLink declarations.
  • Symbols (article 16) — the document outline groups child ACs under the outlineLabel: 'AC'.
  • Refactoring (article 17) — renaming FEATURE-156 to FEATURE-156-revised rewrites the declaration and every @ReferenceLink pointing at it, atomically.
  • Projection (article 18) — the form projection auto-renders the four properties as form fields; the table projection renders the acceptance collection as a grid; the diagram projection renders the blocks @ReferenceLink as graph edges.
  • Generator (article 19) — the source generator walks the Concept and emits a FeatureValidator.ts, a FeatureBuilder.ts, and a FeaturesIndex.ts.

One declaration, eleven downstream consumers. The mass-production property is the architectural payoff for the discipline of keeping the M2 small.

A natural objection: surely we need a fifth decorator for something — for cross-aggregate constraints, for derived properties, for events the Concept emits, for the auditable change log? The answer is consistently no, and the discipline that produces the no is worth naming.

Cross-aggregate constraints (e.g., "no two Features can have the same id"): these belong to the Diagnostics micro-DSL, expressed as a custom validator that walks the Structure Model. They do not belong in the kernel because their expression requires a query language (the kernel deliberately does not invent one — it exposes the AST and lets the Diagnostics micro-DSL choose its own).

Derived properties (e.g., Feature.totalACWeight = sum(acceptance, ac => ac.weight)): these belong to the consuming code, as plain TypeScript getters on the class. They are not part of the metamodel because they are computed, not stored.

Events (e.g., FeatureCompleted, FeatureCancelled): these belong to a separate Events DSL (the cmf-design series describes one in chapter 03). The IDE meta-DSL does not need to know about events; the events DSL is its own subject with its own decorators, and the IDE meta-DSL would describe an IDE for the events DSL if asked.

The auditable change log: provided by the kernel runtime through EditLog (article 05), declaratively at no per-Concept cost.

The pattern is consistent. Every plausible fifth decorator either belongs in a downstream micro-DSL (where it can be optimised for its concern) or in user code (where it can be customised) or in a separate DSL altogether (where it has its own metamodel). The kernel stays small; the four decorators carry the load.

The same exercise applies in MPS, where the Structure aspect has roughly four equivalents (Concept, Property, Child, Reference) — and only those four. MPS authors who reach for a fifth are usually told to add it to a separate aspect (Behavior, Constraints, Editor, Generator). Forty years of metamodelling experience converge on the same number.

The kernel's Structure Model — built at extraction time by walking @Concept declarations across the workspace — is the single read API every micro-DSL consults. Its public surface, deliberately small:

interface StructureModel {
  readonly concepts: ReadonlyMap<ConceptId, ConceptDeclaration>;
  getConcept(id: ConceptId): ConceptDeclaration | undefined;
  isAssignable(child: ConceptId, parent: ConceptId): boolean;
  childLinksOf(id: ConceptId): readonly ChildLinkDeclaration[];
  referenceLinksOf(id: ConceptId): readonly ReferenceLinkDeclaration[];
  propertiesOf(id: ConceptId): readonly PropertyDeclaration[];
  walkSubtypes(id: ConceptId): IterableIterator<ConceptDeclaration>;
}

Every micro-DSL gets the model from the kernel at startup, queries the parts it needs, builds its own internal index. The model itself is immutable per scan — adding a new @Concept declaration triggers a re-scan and a new model is published. Micro-DSLs subscribe to model changes through a dedicated kernel hook so their internal indices stay in sync without polling.

The MPS counterpart is the Structure Aspect's runtime introspection API; the equivalence is structural. The TypeScript spelling is just the idiomatic one for our consumers.

What this article verifies

This article verifies the four acceptance criteria of FEAT-MICRODSL-04 declared in assets/features.ts:

  • m2DecoratorsSpecifiedWithSemantics — the four sections specify each decorator with its options, examples, and consumer impact.
  • conceptHierarchyAndAbstractnessExplained — the @Concept section names extends, abstract, the inheritance chain materialisation, and the abstract-cannot-be-instantiated rule.
  • propertyConstraintsAsValidatorSeedStated — the @Property section names the auto-derivation in Diagnostics (article 11) and the mass-production property.
  • referenceVsChildLinkContrasted — two consecutive sections specify each, with a side-by-side worked example showing the cardinality grammar, the cascade vs. reference semantics, and the Refactoring impact.

What article 05 picks up

Article 04 specified the metamodel as data — the static description of what Concepts exist. Article 05 specifies the runtime — the live state, the mutation contract, the identity guarantees, the idempotence pattern, the local telemetry. Together, articles 04 and 05 describe the kernel completely and the suite is then ready to add micro-DSLs on top. Read 05 next.

⬇ Download