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 II: The Meta-Metamodel — Custom Requirement Flavors

Epic, Feature, Story, Task — that's one flavor. SAFe uses Portfolio Epic, Capability, Feature, Story. Your client uses Program, Capability, Requirement, Work Item. Your maintenance team uses Bug, SupportTicket, Patch. The M3 layer handles all of them.

Every enterprise I have worked with over twenty years has its own vocabulary for requirements. Not because they are being difficult — because their domain demands it. An automotive supplier tracking safety goals under ISO 26262 does not think in "Epics." A bank under CMMI does not think in "Stories." And yet every tool on the market forces one hierarchy and prays the renaming holds.

It does not hold. This post explains why, and what the M3 layer does about it.

The Problem: Hardcoded Hierarchies

Most requirement tools hardcode their hierarchy. Jira gives you Epic, Story, Sub-task. Azure DevOps gives you Epic, Feature, User Story, Task. If your organization uses different terminology, you rename fields and hope the semantics survive the translation.

They rarely do. Here is what actually happens:

  1. You rename "Epic" to "Program." The tool still treats it as an Epic internally. Reports say "Epic." Integrations say "Epic." Your stakeholders see "Program" in the UI and "Epic" in the API response. Confusion ensues.
  2. You add a level. Jira does not natively support five levels. You bolt on a plugin, create a custom issue type, wire up link types. The hierarchy is now held together by configuration duct tape that breaks on every major version upgrade.
  3. You change the rules. In SAFe, an Enabler can be a leaf under Feature. In your custom hierarchy, a Requirement can have sub-requirements. The tool assumes fixed depth. You work around it with labels and filters. Six months later, nobody remembers which label means what.

The root cause is architectural: the hierarchy is structural, baked into the data model. What we need is a hierarchy that is configural — defined once, enforced everywhere, changeable without touching code.

The M3 Layer: What a "Requirement Level" IS

The M3 meta-metamodel does not define Epic or Story or Program. It defines what a requirement level CAN be:

interface RequirementLevelDef {
  name: string;
  allowedParents: string[];    // empty = root level
  hasAcceptanceCriteria: boolean;
  hasEstimates: boolean;
  isLeaf: boolean;
  canReferenceAny: boolean;    // true for Bug, SupportTicket
}
public record RequirementLevelDef(
    string Name,
    string[] AllowedParents,       // empty = root level
    bool HasAcceptanceCriteria,
    bool HasEstimates,
    bool IsLeaf,
    bool CanReferenceAny);         // true for Bug, SupportTicket

The scanner, the API, and the dashboard all see "typed nodes with parent constraints." They never know whether a node is called "Epic" or "Program" or "Goal." The flavor is a skin, not a structure.

This is the key insight. When the engine operates on RequirementLevelDef, it does not care about vocabulary. It cares about: can this node have children? Does it carry acceptance criteria? Is it a leaf? Can it reference any other node? Those are structural questions with boolean answers. The name is just a label for humans.

Pre-Built M2 Flavors

tspec ships with four built-in flavors. Each is a complete M2 definition — a set of RequirementLevelDef instances that the M3 engine consumes.

Agile (default)

Level Parents Has ACs Leaf
Epic (root) No No
Feature Epic Yes No
Story Feature Yes No
Task Story Yes Yes

SAFe

Level Parents Has ACs Leaf
Portfolio Epic (root) No No
Capability Portfolio Epic Yes No
Feature Capability Yes No
Story Feature Yes Yes
Enabler Feature Yes Yes

CMMI

Level Parents Has ACs Leaf
Goal (root) No No
Requirement Goal Yes No
Sub-Requirement Requirement Yes Yes

Enterprise-Custom (industrial example)

Level Parents Has ACs Leaf
Program (root) No No
Capability Program Yes No
Requirement Capability Yes No
Work Item Requirement Yes Yes

All four flavors produce the same shaped output: a tree of typed nodes. The scanner walks the tree the same way. The compliance validator checks coverage the same way. The dashboard renders the same way. Only the labels and depth change.

Custom Flavor Configuration

To define your own flavor, create a .tspecrc.json at the root of your repository:

{
  "flavor": {
    "name": "enterprise-custom",
    "levels": [
      { "name": "Program", "parents": [], "hasACs": false, "isLeaf": false },
      { "name": "Capability", "parents": ["Program"], "hasACs": true, "isLeaf": false },
      { "name": "Requirement", "parents": ["Capability"], "hasACs": true, "isLeaf": false },
      { "name": "WorkItem", "parents": ["Requirement"], "hasACs": true, "isLeaf": true }
    ],
    "crossCutting": [
      { "name": "Bug", "canReferenceAny": true, "hasACs": true },
      { "name": "SupportTicket", "canReferenceAny": true, "hasACs": true }
    ]
  }
}

Then run:

tspec init --flavor=enterprise-custom --lang=ts

This reads the config and generates the abstract classes with correct generic constraints. You get Program, Capability, Requirement, WorkItem, Bug<TTarget>, and SupportTicket<TTarget> — all type-safe, all with the parent constraints baked in at the type level. No renaming. No configuration duct tape. Real types.

Cross-Cutting Types: Bug<TTarget> and SupportTicket<TTarget>

Bugs and support tickets are not IN the hierarchy. They reference INTO it.

Bug<TTarget> takes a generic parameter constrained to any requirement type. The bug knows which feature it is filed against — not through a string ID, but through the type system. This is fundamentally different from an orphaned bug floating in a backlog with a "Related To" link that nobody maintains.

TypeScript:

export abstract class NegativeTotalBug extends Bug<CheckoutFeature> {
  readonly severity = BugSeverity.Critical;

  /** Negative amounts are rejected at checkout with a clear error message. */
  abstract negativeAmountIsRejected(): ACResult;

  /** Existing orders with valid amounts are unaffected by the fix. */
  abstract existingOrdersAreUnaffected(): ACResult;
}

C#:

public abstract record NegativeTotalBug : Bug<CheckoutFeature>
{
    public override string Title => "Orders crash when total is negative";
    public override BugSeverity Severity => BugSeverity.Critical;

    public abstract AcceptanceCriterionResult NegativeAmountIsRejected(
        OrderId order, decimal amount);

    public abstract AcceptanceCriterionResult ExistingOrdersAreUnaffected(
        OrderId order);
}

The scanner and dashboard show bugs under their target feature in the hierarchy tree. The coverage report includes bug-fix ACs alongside feature ACs. When you ask "is CheckoutFeature fully covered?", the answer includes both the feature's own acceptance criteria and the bug-fix criteria that were added after launch.

Support Levels: L1, L2, L3

Support levels are a first-class DSL concept, not an afterthought label.

export enum SupportLevel {
  L1_Helpdesk = 'L1',
  L2_Application = 'L2',
  L3_Development = 'L3',
}

Each support ticket carries a level, an escalation path, and an SLA:

export abstract class LoginTimeoutTicket extends SupportTicket<AuthenticationFeature> {
  readonly level = SupportLevel.L2;
  readonly escalatesTo = SupportLevel.L3;
  readonly sla = { response: '1 business day', resolution: 'per sprint' };

  /** Reproduce the timeout on staging with the reported credentials. */
  abstract reproduceTimeout(): ACResult;

  /** Apply fix and verify login completes within 2 seconds. */
  abstract fixApplied(): ACResult;

  /** Regression test covers the timeout scenario. */
  abstract regressionTestPasses(): ACResult;
}

Each support level has its own workflow:

  • L1 (Helpdesk): Triage, Assign, Resolve
  • L2 (Application): Diagnose, Fix, Verify
  • L3 (Development): Root Cause, Patch, Regression Test, Deploy

Workflow gates enforce discipline: L2 must attempt a fix before escalating to L3. L3 must have a regression test before closing. These are not suggestions — they are compile-time constraints. If an L3 ticket class does not declare a regression-test AC method, the generator flags it.

Maintenance Mode

Here is a truth that most tools ignore: the majority of a B2B application's lifetime is spent in maintenance. New features ship for six months. Bug fixes and support tickets run for years.

When a project enters maintenance (no new features, only bugs and support), the tspec dashboard switches to a dedicated maintenance view:

  • Bugs by severity, support tickets by level, patch coverage
  • Hierarchy tree with stable features grayed out and active bugs highlighted in red
  • Trend charts showing bug-fix velocity, support ticket throughput, and regression risk
  • SLA compliance — which L2 tickets breached response time? Which L3 patches shipped without regression tests?

This is not an afterthought bolted on after launch. It is a first-class mode that reflects the reality of enterprise software: months — sometimes years — of maintenance work are the norm, not the exception. The M3 layer makes this possible because bugs and support tickets are structurally identical to features and stories. They are typed nodes with parent constraints, acceptance criteria, and workflow states. The engine treats them the same way.

The M3 Insight

Diagram

The scanner, the API, and the dashboard never see "Epic" or "Program" or "Bug." They see typed nodes with parent constraints and typed references to other nodes. The flavor is configuration. The persistence layer stores nodes and references. The M3 layer is the engine; the M2 flavor is the paint job.

This connects directly to the CMF M3 meta-metamodel: the same four-layer architecture (M3 to M2 to M1 to M0) applied to requirements instead of domain models. If you have read the CMF series, this pattern should feel familiar. If you have not, the short version is: M3 defines concepts, M2 defines a domain-specific vocabulary using those concepts, M1 is the code you write, M0 is runtime data. Each layer constrains the one below it.

Before: Orphaned Bugs, Flat Hierarchy

Diagram

After: Typed References, Full Visibility

Diagram

The difference is not cosmetic. In the "before" world, bugs exist in a separate list. Nobody knows which feature they belong to. Coverage reports ignore them. In the "after" world, NegativeTotalBug is structurally linked to CheckoutFeature. The compliance validator counts its ACs. The dashboard shows it in context. The maintenance view tracks its lifecycle.

That is what the M3 layer buys you: the freedom to use any vocabulary your organization needs, while the engine underneath sees exactly the same typed, constrained, verifiable structure.


Previous: Part I: The Product Vision | Next: Part III: TypeScript Hierarchy Redesign