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

Chapter 01b — Historical Path: Feature First, Requirement Later

The five-axis level-up chapter 00 named is not a design-from-scratch. It is the end of a three-year history of starting with what was local and touchable — a Feature, because it lives in one test file — and only later extracting what was shared and abstract: a Requirement, because it lives across test files.

Chapter 01 walked the construct-by-construct migration map between typed-specs and the new package. What it did not narrate is why the map has the shape it has.

Why is Feature the older, more load-bearing concept? Why did Requirement only earn a type in April 2026, after months of sitting in frontmatter and tag lists without a class to back it? Why does the running example — FEATURE-TRACE-EXPLORER-TUI — satisfy three Requirements today when, six weeks ago, the @Satisfies decorator did not exist?

The answer is not a design exercise. It is a history.

A local-first concept grew first because locality is cheap. A shared-across-files concept grew later because sharing is something you notice only after you have written the same thing three times.

The gap chapter 00 named is, in one sentence, the gap between what is easy to decorate and what is easy to factor out. This chapter is the chronological version of that sentence.

The history has six phases. Four are behind us; two are the near-past of April 2026.

Each phase is small enough that the force moving it — the particular bit of friction that pushed the design one notch forward — can be named and kept in view.

A phase, in the sense I am using the word, is not a milestone on a roadmap. It is a stable configuration — a shape the codebase held for long enough that it made sense to describe the shape before describing the change. The phases in this chapter are separated by moments of friction, not by releases. Each phase ends when the pressure for the next shape has become acute enough to produce it.

Read the chapter as an attempt to keep that force visible. If you have worked on any traceability system long enough to wonder why your own tool feels more complicated than it "should", one of the phases below will probably match where you stopped.

I say "where you stopped" rather than "where you are" deliberately. A team can stop at any phase for good reasons. Stopping at Phase 2 — Feature-only, no Requirement stratum, no cross-cutting policies to extract — is a stable, shippable configuration for a small team with a coherent product and a single authorial voice. Stopping at Phase 3 is a stable configuration for a product team whose customers do not yet ask for register pluralism. Stopping at Phase 4 is where most multi-team projects end up. Phase 5 is where a public library lands. Phase 6 is where a library that also hosts its own meta-ontology — as this one does — has to land. None of those stopping points is wrong; they are calibrated to different kinds of project.

Before the first phase, a short methodological note. The temptation in a chapter like this is to retrofit a coherent narrative onto a pile of small decisions that were, at the time, opportunistic. I will try not to do that.

Where a phase's move was genuinely deliberate — where I sat down, thought about the shape, and committed to a direction — I will say so. Where a phase's move was accidental, emergent, or reactive — where the code pushed me into a shape I would not have predicted at the start — I will say that too.

The distinction matters. A chapter that claims every step was planned would misrepresent how typed abstractions actually arrive in a live codebase, and would therefore mislead a reader trying to adopt the pattern in their own project. Typed abstractions arrive late, messily, and in response to specific frictions. That is the grain. The chapter's argument depends on that grain being visible.

A second note on scope. I will not, in this chapter, try to relitigate the decisions of typed-specs or typed-specs-product. Those series are their own pieces of writing, with their own intended audiences. Where they show up here, they show up as phases of a history, not as objects of critique.

The purpose is to trace how one design produced the pressures that the next design had to resolve. If that reads, at moments, like a critique of the earlier work, that is a side effect of writing honestly about limitations. The earlier work was correct for its stopping point. The limitations are what forced the next stopping point to be further along.

Phase 1 — the C# origin, roughly 2022 through 2024

The feature-tracking lineage did not begin in TypeScript. It began in C#, in a side-project I kept coming back to from 2022 through 2024 — the feature-tracking C# series, twenty-plus posts long by the time the TypeScript port happened.

The decision to write about it on this site came later; the code had been shipped, broken, refactored, and re-shipped several times before any of the posts were written. A good chunk of the early commits, the ones that settled the attribute shape, happened in late evenings between C# contracts where the day job was a conventional enterprise CRUD codebase and the side-project was a reaction to that day-job's near-total absence of any machine-checkable link between requirements and tests.

The C# lineage was attribute-driven. [Feature("NAV")] sat on a test class. [Implements(nameof(NavigationFeature.TocClickLoadsPage))] sat on a test method.

A Roslyn analyser — the piece of infrastructure that made the whole thing feel worth shipping — read the attributes at compile time and flagged drift: a typo in an AC name was an analyser diagnostic, not a runtime surprise. A source generator produced a compliance table from the attribute graph.

The nameof() expression is the C#-specific trick that makes the whole pattern work without magic strings. It is a compile-time operator that returns the string name of a symbol. nameof(NavigationFeature.TocClickLoadsPage) returns the string "TocClickLoadsPage", but only if the symbol exists; rename the method, and every call site of nameof that pointed at it becomes a compile error until you rename there too.

That single operator is what carries refactor safety in the C# lineage. Without it, the [Implements] attribute would be taking a bare string, and renaming an AC would silently break every attribute that referenced it. The TypeScript port replaces nameof with keyof T & string, which is the same idea expressed in a different language's grammar: the compiler, not the programmer, holds the relation between symbol and name.

The two pieces of tooling above — the analyser and the generator — were what turned the attribute pattern from a piece of bookkeeping into a piece of load-bearing infrastructure. Without the analyser, the attributes were string magic: nothing stopped a typo. Without the generator, the attributes produced no aggregate view: you could not ask is feature NAV fully covered? without running a separate script.

With both of them in place, the attribute graph became something the compiler could reason about. That is the move that made the lineage credible. The move is worth naming, because every subsequent phase — the TypeScript port, the product surface, the refactor, the monorepo extraction — is a version of the same move applied to a different pair of artefacts.

Feature came first in that world for the same reason it came first in every world that followed. An [Feature] attribute sits on a test method. A test method lives in one file. A file is something a developer can open in their editor and hold in their head.

It is the smallest unit of "this is what I am testing today" that the language's own scope rules let you write without ceremony. You do not need to decide on a taxonomy to write [Feature("NAV")]; you only need to decide on the name of the thing this specific test is about. The attribute is local to the call site. That locality is what made the pattern cheap enough to ship.

It is worth pausing on the word cheap. When people say a design is "cheap", they often mean it in the derogatory sense — as in, "a shortcut". I mean it in the cost-of-adoption sense. A pattern is cheap when a developer can try it without committing to a taxonomy, a tree, a set of parent classes, or a project-wide design session.

[Feature("NAV")] passes that bar. The developer who types it does not have to know what the N other Features will be called; they do not have to know whether NAV will eventually become a child of some larger Epic; they do not have to know what the Requirement behind NAV is. They know what this test is about. They write the attribute. That is the whole commitment.

The Roslyn analyser accepts the attribute even if no other Feature has ever been defined in the codebase. The cost of adoption is one line.

What the C# lineage did not do — and this is the point that matters for the history — is grow a Requirement stratum. The Roslyn analysers tracked Feature-to-test. They did not track Requirement-to-Feature. They had no reason to.

Every Feature in the C# codebase "stood for" a requirement in the designer's head, but the designer's head was the only place that relation lived. The mapping was perfect because it was one-to-one.

The collapse between "the requirement I care about" and "the Feature class I wrote" meant the Requirement concept never had to earn a type; the Feature was already the requirement, for all practical purposes.

This is a common pattern in small or mid-sized codebases. A concept that, at scale, would need to be lifted into its own type can, at the current scale, ride on the back of a neighbouring concept without anyone noticing. The two concepts share a class, a file, a name. The drift between them is zero because there is nowhere for them to drift to.

In that configuration, the smaller concept is invisible even to the designer who is, in some sense, using it. Every decision the designer makes about Feature is also, implicitly, a decision about Requirement — but the implicitness is what keeps the system cheap. If you made the Requirement explicit while it was still collapsed onto Feature, you would pay the cost of two types to get the expressive power of one.

I want to dwell on that collapse for a moment, because it will come back in every subsequent phase. A "1:1 collapse" means that two conceptually distinct things — the requirement (the why) and the feature (the what) — are being carried by a single class in the code. The collapse is stable as long as the two things stay in lockstep.

As soon as they drift — as soon as one requirement ends up covered by multiple features, or as soon as one feature covers multiple requirements, or as soon as a requirement has a register the feature does not carry — the collapse has to be un-collapsed. The typed-specs series did not encounter drift. The new package does. Phase 4 of this chapter is the story of the drift, and Phase 5 is the story of the un-collapse.

This mattered for three years. It explains why, when the TypeScript port happened in March 2026, the port was a Feature port. You cannot port what was never built.

A note on ordering. The C# feature-tracking series is the technology branch the new TypeScript work is parallel to, not a predecessor. I want to be careful about that word. The TypeScript lineage is not a migration off C#. Both lineages are alive; both target the same kind of project; both share the same philosophical anchor. The relation is sibling, not parent-child.

The TS-side bridge post makes that port explicit: it is a deliberate translation of the C# pattern into TypeScript decorators and keyof T, published as a standalone piece before the typed-specs series gave the pattern a seven-part home. The bridge post is where a reader who knows the C# version meets the TypeScript version for the first time; it is a post that exists specifically to say the same philosophy works outside .NET.

The bridge-post decision was itself instructive. When I wrote it, the pattern had existed in C# for about two years. The natural thing to do would have been to write a "how I ported it" narrative — a migration journal. I did not.

The bridge post is written as a standalone implementation, not as a migration, because the two languages have different idioms for the same philosophical move. C# uses attributes, TypeScript uses decorators; C# uses nameof, TypeScript uses keyof T; C# uses a source generator, TypeScript uses a compile-time AST scanner. A migration narrative would have flattened those differences; a standalone implementation kept them visible.

That decision is what let the TypeScript lineage evolve its own path without being retrofitted into C#'s shape. The Requirement stratum that emerged in Phase 4 is, I think, only discoverable along the TypeScript path — and I will say a little more about why in the next section.

What the C# lineage inherits into the TypeScript one is the Feature-first instinct. The attribute is on the test. The test is in a file. The file is what you open. Everything that happens later is going to have to fight, or accommodate, that first move.

Phase 2 — the TypeScript port, March 2026

The typed-specs series in March 2026 ported the C# shape directly into TypeScript. Feature became an abstract class. Acceptance criteria became abstract methods. Three decorators — @FeatureTest, @Implements, @Exclude — carried the test-to-feature relation at the same granularity the C# attributes had carried it.

A compliance scanner, written in about three hundred lines of TypeScript, walked the decorator graph and produced a coverage table. 112 acceptance criteria across 20 features, all bound at compile time, all verified at test time. The typed-specs/01-why.md post makes the motivation explicit: the green-bar illusion is not a missing-tests problem; it is a missing-link problem.

The translation choices were deliberate. abstract class Feature instead of abstract record Feature<TParent> gave up C#'s first-class hierarchy in exchange for a flatter, easier-to-explain M2.

keyof T & string in the @Implements<T>(ac) decorator replaced C#'s nameof() trick, keeping compile-time typo detection while losing nothing structural.

The flatness of the M2 is worth dwelling on. A common move in port-style ports is to reproduce the source language's hierarchy faithfully, even when the target language's idioms would prefer something simpler. That move produces ports that feel like translations: the shape is preserved, the idioms are awkward. The typed-specs port resisted that move.

Feature became a single abstract class rather than a generic type parameterised on a parent. Consumers who wanted hierarchy could use composition — a Feature could contain references to other Features — rather than inheritance. The flat M2 was, in retrospect, the single most important design choice of the TypeScript port, because a flat M2 is what makes it possible to introduce a second M2 type later (namely Requirement) without first disentangling a pre-existing hierarchy.

The compliance scanner replaced Roslyn's source generator; the scanner runs at CLI time rather than compile time, which is a small latency regression but costs nothing in correctness because the keyof T check already runs at tsc time.

Each of those trades was weighed and accepted. The pattern that came out of them is legible, defensible, and — the part that matters for this chapter — still Feature-only.

A brief aside on the shape of those trades. The TypeScript ecosystem does not have a direct equivalent of Roslyn's analyser-plus-generator pair. What TypeScript offers instead is the combination of structural types, keyof inference, and conditional types, which together can express most of what Roslyn would check at compile time — but only at the point where the types are used, not at the point where they are declared.

This is a real asymmetry. Roslyn can ask "does every test in this solution satisfy the coverage gate?" at the moment the solution compiles. TypeScript can ask the equivalent question only when a scanner walks the decorator graph at CLI time. The gap is small in wall-clock terms — a few seconds of scanning rather than instant feedback in the IDE — but it is a gap the TypeScript port had to accept, and the CLI scanner is how it accepts it.

The port was faithful. It was also, deliberately, limited. The word Requirement was in the tags, in the descriptions, in the folder name — requirements/features/navigation.ts is the canonical example — but the type the series modelled was still Feature. This was not an oversight. It was a pedagogical choice I will now defend.

Shipping a single-class pattern that a reader can implement in one afternoon is a different kind of writing than shipping a complete traceability ontology. The former has a chance of being read to the end; the latter rarely does.

Typed-specs was written as a ladder, one rung at a time: here is a class, here are three decorators, here is the compliance scanner, here is the quality gate, here is the conclusion. Every piece of the ladder stopped short of the next one on purpose.

Adding a Requirement stratum on day one would have drowned the clarity of abstract class Feature — readers would have arrived at the Feature chapter asking but which Requirement does this satisfy? and the answer would have been we will get to that in chapter nine, which is the answer that ends a reader's patience.

The ladder worked because it stopped short. It is, in retrospect, a well-known fact about pedagogical writing that the first pass through a concept should model one thing well rather than three things adequately. Typed-specs modelled Feature well. It did not model Requirement at all.

The word Requirement sat in the frontmatter as a kind of promissory note: we will come back for this. Chapter 00 of this series is the close reading of that promissory note. This chapter is the history of why the note was written in the first place.

There is a second, quieter reason the port kept Requirement out of the model. The 1:1 collapse that had hidden Requirement in the C# lineage was still hiding it in the TypeScript one. Every Feature "stood for" a single requirement, because the codebase the port was demonstrated on — this very website — is a solo project with a single designer.

Solo projects collapse authorial intent onto implementation: the Requirement and the Feature have the same author, the same moment of conception, the same audience. In that collapse, a Requirement stratum would have had nothing to do. A Feature subclass was already carrying the "why". There were no cross-cutting policies to extract. There were no shared parents to factor out. There was no second register in which the same rule needed to speak.

The collapse was genuine. It is only the move to April — the monorepo carve-out, the multiple packages, the cross-cutting REQ-DOG-FOOD policy — that dissolves the collapse and makes Requirement a thing that has to be said out loud.

The collapse is also why the typed-specs running example — NavigationFeature — reads as obviously a Feature and not obviously a Requirement. Consider what the class looks like: an ID, a title, a priority, and eight abstract methods with names like tocClickLoadsPage and anchorScrollsSmoothly. Each method reads as an assertion of observable behaviour. None of them reads as a policy.

The class is shaped like a deliverable. That shape is correct for a Feature and wrong for a Requirement. If you tried to write a Requirement class using the same template — an ID, a title, a priority, and eight abstract methods — the methods would not know what to do.

They would either repeat the Feature's methods (in which case the Requirement is a redundant copy) or they would say things like mustBeAccessible() and mustWorkOnMobile(), which are not observable behaviours but injunctions. Injunctions belong in a Requirement class; observable behaviours belong in a Feature class. The typed-specs template was one of those two shapes, not both.

So: Phase 2 is the phase where the word was loud and the type was quiet. That configuration held, productively, for a month. During that month, the pattern was tested on the actual website, the compliance gate was wired into the test suite, the keyof T checker caught its first few typos in anger, and the blog series shipped seven parts.

Nobody complained about the missing Requirement stratum. The 1:1 collapse made the absence invisible. That is the phase's most interesting property: a gap can be both real and invisible at the same time, as long as nothing is yet pulling on the side of the collapse that would expose it. The product series is what started pulling.

Phase 3 — tspec product surface, March 2026

Running, roughly in parallel to typed-specs, was a second series — typed-specs-product. Where typed-specs was a seven-part implementation tour, typed-specs-product was a thirteen-part product-design exercise: what would it look like to turn the pattern into a B2B product for enterprise teams?

The series introduced a custom flavors layer (Epic / Feature / Story / Task is one flavor; SAFe is another; your client's bespoke flavor is a third), a workflow DSL, language backends for seven languages, a Diem CMF instance for storage and dashboarding, approval workflows, Jira and Azure DevOps integrations, and more.

Each of those additions, taken individually, is a reasonable enterprise concern. Collectively, they describe what it would take to turn the seven-part implementation from typed-specs into a product that a company other than the author could buy, deploy, and operate.

The thirteen chapters of typed-specs-product walked through each concern in turn, showing how the underlying Feature class would be extended, wrapped, or integrated to serve that concern. The series was more speculative than typed-specs — it was an exercise in imagining a product, not an exercise in shipping one — but the speculation was disciplined, and it surfaced concerns that the pure implementation series had not had reason to articulate.

The jump from typed-specs to typed-specs-product was, I should be clear, not a technical escalation. It was a surface-area escalation. The type-level foundation was the same seven-part implementation from the first series; what changed was the number of concerns a hypothetical enterprise customer would want to see wrapped around that foundation.

Backlog management, sprint planning, stakeholder dashboards, audit trails, cross-team dependencies, manual test tracking, QA review workflows, PDF export for regulatory filings — the surface widened in thirteen directions at once. In all thirteen, the central class was still Feature. Requirement was still rhetorical.

Structurally, this was a significant step. It widened the surface. But the type-level M2 foundation was still Feature-only. The thirteen product-surface concepts were added around the Feature class, not above it.

A backlog item was still a Feature. A workflow state attached to a Feature. A language backend produced scans of Features. The flavors layer parameterised what Features could nest inside — Epic-as-parent-of-Feature — but Epic was a Feature subclass in all but name; there was no separate Requirement kind with a different relation to Feature.

The Epic-as-Feature reading is worth pressing on. In a lot of enterprise traceability tools, Epic is a hierarchical parent of Features. It looks like a superclass. It contains Features the way a folder contains files.

The typed-specs-product series preserved that reading by making Epic another subclass of Feature with a different flavor tag. That move worked for the product surface but quietly broke the conceptual purity: Epic-the-container is not the same kind of thing as Feature-the-deliverable, and giving them the same base class forces an inheritance relation that does not match their real relation.

Retrospectively, Epic is a Requirement with cluster semantics — it groups several Features under a shared policy. Writing it as a Feature subclass was the flat, Feature-only way of expressing that; it was also the first visible sign that the Feature-only M2 was starting to bend under weight it did not want to carry.

typed-specs-product/10-dogfood.md is the chapter to flag here. It gestured at dog-fooding at the product level: tspec-the-SaaS would track its own features using its own instance of tspec. The gesture was the right gesture; it is the same instinct that now drives REQ-DOG-FOOD in the new package.

But the gesture lived at the product-instance layer. At the type level — the layer where @FeatureTest, @Implements, and the decorator chain operate — the pattern was still Feature-only. Tspec's own features were Feature subclasses. The Requirement they collectively satisfied was implied, named only in prose: "the product eats its own dog food".

There was no REQ-DOG-FOOD class, no @Satisfies(ReqDogFood) on the relevant Features, no compliance gate that would refuse to ship if the dog-food loop regressed. The gesture hinted at the loop. It did not close it at the decorator source.

This distinction between the product-instance layer and the decorator-source layer matters enough that I want to state it twice. A tool that tracks its own features at the instance layer is dog-fooding in the weak sense: the data about the tool is stored in the tool. A tool that tracks its own features at the decorator-source layer is dog-fooding in the strong sense: the types that the tool uses to describe consumers' features are the same types the tool uses to describe itself.

Typed-specs-product reached the weak sense. The new package reaches the strong sense. The difference is the presence of REQ-DOG-FOOD as a typed Requirement, referenced by @Satisfies(ReqDogFoodRequirement) on the Features that implement the self-validation infrastructure. That specific arrow — from a Feature to a Requirement, checked at compile time — is the arrow that did not exist in Phase 3.

This is the phase where you can start to see the pressure building. Typed-specs-product was already writing sentences like "the workflow engine must enforce coverage-gated transitions". That sentence is clearly not a Feature; it is a rule about Features.

In a Feature-only world, where does it go? It goes into the prose of a chapter and into the comment block of the relevant workflow engine Feature. And it stays there, because the type system has no slot for it.

A reader coming to the workflow engine implementation file six months later, without the prose of the chapter, would see the Feature class and its ACs and would have no compile-time way of knowing that the rule existed. This is exactly the kind of drift that typed-specs was invented to prevent — applied to the wrong kind of object. Features were protected from drift. Requirements, which were still prose, were not.

I will name the general principle the product series accidentally surfaced: the object a DSL protects is the object a DSL takes seriously. Typed-specs took Features seriously because the @Implements decorator pointed at them. It did not take Requirements seriously because no decorator pointed at them.

The product series, by generating more and more prose-level sentences about Requirements without adding a decorator that pointed at them, widened the zone where the DSL had opinions but no types. That zone is exactly where drift accumulates.

You can feel it in the product series' late chapters — the language gets more elaborate, the types stay the same, and the gap between what the prose asserts and what the code enforces keeps growing. The asymmetry is unmistakable in retrospect. At the time it was just the ordinary feeling of writing a lot of prose.

The typed-specs-product series ended with that asymmetry unresolved. By the time the asymmetry was visible enough to demand a fix, the calendar had already turned to April.

Retrospectively, there is a specific chapter in the product series where the asymmetry first became something I could name. That chapter discussed workflow transitions — the idea that a Feature moves through states like Draft, In Review, Approved, In Development, Done, and that each transition has preconditions. The preconditions, in that chapter, were written as prose: "a Feature cannot transition from In Review to Approved unless every AC has a verifying test."

That sentence is a rule about a Feature. It is not itself a Feature. It has the grammar of a Requirement — a policy that applies to a class of Features — but at the time, the series did not have a typed slot for it. It went into prose. It also went into a schematic diagram. But it did not go into the type system.

The chapter after that one had a similar sentence about coverage thresholds, and the chapter after that one had a similar sentence about stakeholder sign-off. By the end of the product series, there were perhaps a dozen such sentences, each homeless in the type system. That twelve-sentence pile was the visible face of the missing Requirement stratum.

Phase 4 — the refactor pressure, early April 2026

This is the heart of the chapter. What forced the Requirement stratum to emerge?

Three pressures, stacking. Any one of them alone would have been patchable. The combination demanded a second M2 type.

The first pressure — cross-cutting policy

The first pressure was cross-cutting policy. Multiple Features started sharing a "why" that was not itself a Feature. The clearest case was the dog-food policy: "the DSL must be tested with itself; zero describe/it". That policy applies to at least four Features in the current package — the compliance scanner, the scaffolder, the watch-mode, the TUI trace explorer — and to every test file in the package.

In a Feature-only world, there are two bad options. Option one: create a fake Feature called DogFoodingFeature that owns the policy. But a Feature, by definition, is a deliverable with acceptance criteria; the dog-food policy is a rule about how every other Feature is delivered, not a deliverable.

Shoehorning it into the Feature shape produces a Feature whose "acceptance criteria" read as injunctions to other Features, which is a category error.

A Feature's ACs should be observable properties of the thing being built. An injunction to another Feature is not a property; it is a rule the other Feature has to obey. The two kinds of sentence belong to different syntactic categories.

Forcing them into the same type produces a Feature that reads, when you open its file, like an etiquette manual rather than a specification. The file's ACs say "every other Feature in this codebase should do X" instead of "this Feature does X". That is a confusing thing to open at three in the morning when a test is failing and you need to read the Feature to understand what the test was asserting.

The category error is not merely aesthetic. It also breaks the compliance report. A compliance report that includes the fake DogFoodingFeature will either list zero ACs (because the policy does not decompose into observable behaviours) or list ACs whose meaning depends on other Features existing and obeying the policy, which is not a property the report can check on its own.

Either way, the row in the compliance report is noise. The row sits next to legitimate Feature rows. It dilutes the signal of the report. At scale, with a dozen such fake Features, the report stops being a reliable summary and becomes something a reader has to filter mentally.

Option two: repeat the policy in every affected Feature's prose — in comments, in test names, in chapter drafts. This is the drift failure mode typed-specs was explicitly designed to prevent. The policy would be restated four times; the restatements would, over months, drift apart; the drift would be invisible because no two developers would be reading all four files at the same time.

Six months later, the scaffolder's comment would say "zero describe/it", the TUI's comment would say "no test framework keywords", the compliance scanner's comment would say "tests must use decorators only", and the watch-mode's comment would have been deleted because someone refactored the file and did not realise the comment was load-bearing. The policy would effectively have disappeared. Any new Feature added after that point would have no way to discover that the policy applied to it.

Both options are wrong. The policy needs its own home. A Requirement class called ReqDogFoodRequirement, written once, imported by the four affected Features via @Satisfies(ReqDogFoodRequirement), is the only shape that satisfies both the "do not restate" constraint and the "do not force-fit" constraint.

The second pressure — type pressure from the use site

The second pressure was type pressure from the use site. Once you have decided that you want to write, on a Feature class, something like @Satisfies(Req1, Req2, Req3), you need a type for Req1.

The type cannot be Feature, because Req1 is not a Feature — if it were, we would be back to option one above. The type cannot be string, because a string is the magic-string failure mode the whole typed-specs philosophy exists to avoid. The type has to be Requirement or one of its subclasses.

The type pressure came from the use site — from the act of writing @Satisfies(...) on a Feature and expecting the argument list to type-check. It did not come from a design exercise conducted at the architectural level. It came from the keyboard.

You write the decorator call, you ask the compiler to check it, and the compiler turns around and asks: what is the type of these arguments?

This is a small point worth dwelling on. The reason the Requirement stratum feels inevitable in the new package is that, the moment you try to decorate a Feature with the Requirements it satisfies, there is no alternative. Any honest type for the decorator's argument list forces a Requirement kind into existence.

You can delay it — you can write strings for a while, or you can list Feature subclasses, or you can use a union of string literal types — but each of those is a worse version of the final move. The final move, @Satisfies(Req1, Req2, Req3) with Req* being class references, is the only version the compiler rewards. The stratum emerged from what the compiler would agree to check.

I want to underscore the difference between this kind of pressure and a top-down architectural pressure, because it is the most important methodological point in the chapter. A top-down architectural pressure sounds like "a traceability tool needs Features, Requirements, Tests, and Stakeholders because that is what the ISO 29148 model says." It starts from an external authority and moves toward code.

A use-site pressure sounds like "I tried to write @Satisfies(X, Y) and the compiler refused to let me finish the line until I told it what X and Y were." It starts from code and moves toward a type. The first kind of pressure tends to produce over-fitted ontologies that solve problems the project does not have. The second kind of pressure tends to produce ontologies that match the project's actual grain because they are grown from it.

The new package's Requirement stratum is the second kind. Every Requirement class in the current twenty-two was introduced because some specific @Satisfies or @Refines argument list needed a type. The stratum does not exist because I read ISO 29148 and decided to implement its abstractions.

It exists because the compiler, at a specific moment, refused to accept an argument of insufficient type and thereby forced the type to be invented. That story — the compiler made me do it — is, I think, the healthiest story a type can have.

The third pressure — Requirement clustering

The third pressure was Requirement clustering. Once the first few Requirements had been pulled out, they started to cluster. A family of traceability Requirements — REQ-DISCOVERABLE-TRACEABILITY, REQ-REFACTOR-SAFE, REQ-VISUAL — clearly shared a parent: a higher-level policy that could be informally stated as "the traceability graph must be readable, refactor-safe, and human-navigable".

In a flat Requirement space, you could write three Requirements with overlapping rationale and leave the relation implicit. Six Requirements later, the relation would still be implicit, and the documentation would say the same sentence four times in four places. That is another drift failure mode.

The only way to factor it out was a Requirement-to-Requirement relation — @Refines(ParentReq) — which itself demanded that Requirements be typed entities capable of pointing at each other. A Requirement refining a Feature is a category error. A Requirement refining another Requirement is the obvious move, and it requires the stratum.

The clustering pressure is also where the SysML-style refine and derive relations come into the model. In SysML, a Requirement can refine another Requirement (making the child more specific than the parent) or derive from another Requirement (expressing the child as a logical consequence of the parent).

The new package collapses those two SysML relations into one @Refines decorator for the same reason most practical systems collapse them: the difference between refine and derive is rarely load-bearing in day-to-day use, and preserving both doubles the cognitive overhead of the relation without doubling the expressive power.

The fact that there is even a debate about which SysML relation to use is evidence that the relation itself is needed. The debate only exists at the level of a typed Requirement; it is invisible at the Feature level because Features do not refine each other in the same way.

The fourth pressure — register pluralism

The fourth pressure — I said three, but this one is the back of the third and stacks on top of the others — was register pluralism. Different audiences wanted different registers for the same underlying rule.

A safety-critical engineer reading a typed-specs-style abstract class Feature would not accept it as a safety requirement. There would be no SIL level, no hazard reference, no verification method, no traceable source document. Those are not missing fields the engineer would wave away; they are the whole point of a safety requirement.

The only way to serve multiple registers — default, industrial, lean, agile, kanban — was to parameterise a higher-level class by a Style and let the rule-prose vary on the Style. If Style lived on Feature, every Feature would inherit a register it does not need — a Feature is a deliverable; it does not need a SIL level.

If Style lives on Requirement, then Feature stays clean and the register pluralism happens where it belongs, at the rule layer. This is the fifth axis from chapter 00. It is also, structurally, why Style cannot be a property of Feature: Style attaches to the rule, not to the thing the rule governs.

Another way to see the same asymmetry: a Feature's behaviour does not change based on which audience is reading about it. The TocClickLoadsPage AC tests the same assertion whether the reader is an industrial engineer or a lean manufacturing coach. What changes between audiences is the vocabulary used to state why the Feature has to work that way — which is Requirement territory, not Feature territory.

A Feature is register-invariant because it is a deliverable. A Requirement is register-variant because it is a justification addressed to humans. Styles make the justifications legible to different kinds of humans without changing the deliverables they justify.

The industrial-style pressure is worth singling out because it was, concretely, the pressure that introduced Style as a type parameter rather than as a runtime flag.

A runtime flag — say, a style: 'industrial' | 'lean' property on a Requirement — would have worked for labelling purposes. It would not have worked for typed extension: the industrial style has fields (SIL level, hazard reference, verification method) that the lean style does not, and those fields have to appear on the Requirement class when the industrial style is in use and not appear when the lean style is in use. That is a type-parameter problem.

Requirement<IndustrialStyle> has fields that Requirement<LeanStyle> does not. Encoding that with a runtime flag would have produced optional fields that every style sees as optional, which is exactly the "a field is only meaningful in one register" problem the Style dimension exists to solve.

Making Style a type parameter has a further, less obvious benefit: the compiler can tell you, at the call site, whether you are trying to read a SIL level from a lean-style Requirement. That is a compile error, not a runtime undefined. The type system is carrying the register invariant.

This is why the Style dimension could not be added to the Feature class without wrecking it. Features have no register. A Feature is the deliverable; the deliverable is the same in every register. Forcing a Style parameter onto Feature would have meant every Feature subclass choosing a Style, which would have been noise for every consumer who did not care about register pluralism. The Requirement level is the only place the Style parameter can live without being noise.

Convergence

Three pressures, or four if you split the last one. Cross-cutting policy. Type pressure from the use site. Clustering. Register pluralism. The combination is what made the second M2 type non-negotiable.

Each one was patchable alone: you can carry cross-cutting policies in prose for a while; you can type @Satisfies arguments as strings for a while; you can tolerate unrelated-looking Requirements for a while; you can write one register and ignore the others for a while. Together, the patches contradict each other. The contradictions are what force the refactor.

A brief note on contradiction. "The patches contradict each other" is a specific claim I should back up. If you patch cross-cutting policy with a fake Feature, that fake Feature ends up in the Feature registry and competes with the real Features for the scanner's attention — its coverage shows up in the compliance report as if it were a deliverable, which is wrong.

If you patch @Satisfies arguments as strings, you have to maintain a separate registry of legal strings, which has to be synchronised with the prose-level Requirement list, which drifts. If you patch clustering with prose-level duplication, the clusters become invisible in code and the refactoring cost of keeping them synchronised grows combinatorially with the number of Requirements. If you patch register pluralism with a runtime flag, the type system stops catching register-specific field errors.

Each patch has a local cost that would be bearable in isolation. Stacking the patches makes the costs compound: the compliance scanner's report is now polluted by fake Features, whose string names appear in @Satisfies arguments, which point at entries in a drifting Requirement list, whose register-specific fields are not type-checked. The stack is the contradiction. No single patch fixes the stack. The second M2 type fixes the stack.

The refactor itself

The refactor happened in early April 2026.

It was not a weekend project; it was the convergence of roughly two weeks of small moves — a decorator here, a type there, a test rewritten, a folder renamed — that, in aggregate, pulled Requirement out of the prose and into the type system.

The two-weeks framing is worth a short note. Many refactors of comparable scope take months, not weeks. The reason this one took weeks is that the groundwork had been laid in the previous three phases. Feature was already an abstract class. The decorators were already in place. The compliance scanner was already walking the decorator graph.

Adding a second kind of class — Requirement — to an M2 that already had one kind of class was not a structural move; it was an extension move. The machinery was there. What changed was that the machinery now had a second class of object to walk.

The running example we have been using, FEATURE-TRACE-EXPLORER-TUI, has its current three-way @Satisfies list because of that convergence.

Six weeks ago, the Feature had a comment that said "this is about the DSL dog-fooding itself" and nothing else. Now it has a decorator that the compiler understands.

The distance between those two states — an informal comment and a compile-checked reference — is the distance the entire Phase 4 refactor traversed, concentrated into one example. The comment was a note-to-self. The decorator is a note the compiler also reads. The difference matters because the note-to-self does not survive a refactor in the way the compiler-read reference does.

Rename ReqDogFoodRequirement in its definition file and every @Satisfies(ReqDogFoodRequirement) call site becomes a compile error until renamed. Rename the policy in the comment and nothing catches the drift; the comment is just text, and text does not refactor.

The sequence of moves, in rough order, was: introduce the Requirement<S> abstract class with a Style type parameter; write the first three concrete Requirement subclasses (REQ-DOG-FOOD, REQ-DISCOVERABLE-TRACEABILITY, REQ-REFACTOR-SAFE) as the shape settled; add the @Satisfies decorator with a variadic class-reference argument list; retrofit @Satisfies onto the existing Features that had been carrying cross-cutting policies in prose.

Then: add the @Refines decorator when the first parent-child Requirement pair appeared; add the Style registry and the five preset styles; wire the compliance gate to understand orphan Requirements (Requirements with no satisfier) as a new failure mode; generate the JSON Schema from the Requirement type and register the editor binding.

Each move was small. No move was a rewrite. The codebase never had a big-bang "v2" moment; it had a gradual settling into a shape that had been implicit for a month.

A side remark on the three initial Requirement names. REQ-DOG-FOOD was the most obvious candidate because the policy was already stated (informally) in the README. REQ-DISCOVERABLE-TRACEABILITY was the second, because the trace-core and TUI Features independently needed the same "humans walk the graph" rule. REQ-REFACTOR-SAFE was the third, because the keyof T pattern and the scanner's AST walking were two different Features that both existed to protect the same invariant — that renaming an AC should be a compile error, not a silent coverage loss.

Each of those three emerged because exactly two or three Features needed the same rule. Two is the magic number. One Feature does not justify a Requirement — it is the Feature's own why. Three or more Features is a clear justification. Two Features is the boundary case, and the boundary case is where I chose to err on the side of extracting the Requirement, because the marginal cost of an extra Requirement class (a five-minute wizard session, a dozen lines of code) is so much smaller than the drift cost of leaving the rule implicit across two files.

The full twenty-two-Requirement count did not materialise in April. By April 14th the count was around eight. The remaining fourteen accrued over the following weeks as more Features were added, the scaffolder registry was refactored, the style system was expanded, and each refactor surfaced an additional implicit rule that deserved its own type.

That gradualness is another methodological point worth underlining. The Requirement stratum was not introduced in a single PR. It grew file by file, over several sessions, each session responding to a specific friction point.

This is, I think, the way typed abstractions are supposed to arrive in a living codebase: as a response to a specific friction rather than as a planned milestone. The reverse — "next quarter we will introduce a Requirement layer" — tends to produce layers that are larger than the friction required, because the planning happens before the friction is felt in concrete terms. The friction-first approach keeps the abstraction calibrated to the problem.

Phase 5 — monorepo extraction, 2026-04-14

The extraction of @frenchexdev/requirements into its own package, alongside @frenchexdev/typed-fsm and a dozen other extracted libraries, was the moment the Requirement stratum became fully visible. The extraction itself is worth a short explanation, though most of it belongs to other posts and I will not duplicate their content here.

The monorepo carve-out began because the CV repo had grown too large to hold the shared libraries together with the demonstrator website.

The decorators, the compliance scanner, the scaffolder, the trace-core — all of it had been written, lived, and maintained inside the CV repo's src/ folder, even though it was clearly reusable code that did not have anything to do with my CV.

Carving it into its own package was, nominally, a cleanup move. What it surfaced was more than a cleanup.

The cleanup framing understates what the carve-out actually did. A cleanup is a move that preserves the codebase's existing invariants while tidying its layout. A carve-out that introduces a new type into the package's vocabulary is doing more than cleanup; it is changing the ontology.

The ontology change happened because the act of drawing a clean boundary around the reusable code forced questions the monolithic code had been allowed to ignore. What was the package's public API? What was its contract with consumers? What invariants did it promise? What did it refuse to promise? Each of those questions, answered honestly, produced sentences that pointed at the absent Requirement stratum.

There is an intermediate fact worth stating about the carve-out. The CV repo had, by that point, grown to something like twenty thousand lines of TypeScript across src/, scripts/, test/, and the various build tooling. Maybe half of that was demonstrator-specific: markdown rendering, theme switching, mermaid pipelines, a11y testing.

The other half — the decorators, the scanner, the scaffolder, the trace explorer — was the kind of code that could, in principle, be used by any TypeScript project that wanted compile-time traceability. Keeping the two halves in the same repo had worked for a long time because the demonstrator-specific half was the proving ground for the reusable half.

But the coupling made it impossible to publish the reusable half without dragging the demonstrator in. The carve-out was therefore also a publishability move: the reusable half, on its own, could become a package; the CV, on its own, could become a consumer of that package.

When you carve a package out of a host repo, you have to state, at the package boundary, what the package is for. You have to write a README. You have to decide which types are exported. You have to decide which invariants the package promises its consumers.

Those decisions produce, by necessity, a set of sentences that sound exactly like Requirements.

"The package must support property tests." "The package must validate its own usage." "The package must be refactor-safe."

Each of those sentences, written in the README of the package-being-carved-out, was a Requirement in all but name. Writing the READMEs made the implicit Requirements loud.

It is worth naming why the README is the surfacing surface, specifically. A README addresses consumers who are not the author, which means the author has to write down rules that, between the author and their own code, were already being kept implicitly. The audience change — internal designer to external consumer — is what forces the implicit to become explicit.

A package that never had an external audience — a private utility library used by one person, say — would never be forced to surface its implicit rules. The carve-out was therefore not the moment the rules existed; it was the moment the rules had to be written for a reader other than the author. That distinction matters for the argument the chapter is making. Requirements did not appear because a design session decided to add them. They appeared because a change in audience demanded them.

I will make that claim more concrete. Look at the current packages/requirements/CLAUDE.md or its README. Phrases like "port-driven: analysis core takes a FileSystem port", "no runtime reflection, no string IDs", "100% lines/branches/functions/statements", "zero describe/it permitted" — each of those phrases is a constraint the package promises to satisfy.

Each of them, if violated, should cause the package's own compliance gate to fail. Each of them is therefore, functionally, a Requirement. Before the extraction, those phrases lived in commit messages, in scattered comments, in the designer's head.

The act of writing them down, in one place, at the package boundary, made them visible as a set. Once they were visible as a set, the question "why are these phrases living in a README and not in a type?" became unavoidable.

Then the question was what to do with them. Leave them in the README? That is exactly the documentation-drift failure mode typed-specs exists to prevent. Scatter them into the prose of individual Feature files? Same failure mode. Promote them to classes? That is the move.

And once one of them was a class, the question "which Features satisfy this?" had a well-typed answer, and the @Satisfies decorator had an argument type, and the whole refactor of Phase 4 dropped into place because the boundary of the new package demanded it.

This is the sense in which the extraction caused the Requirement stratum to emerge rather than merely contain it. The monorepo is not scenery. The clean package boundary was the pressure. Without the boundary, the READMEs would not have been written, the Requirements would not have been named, and the @Satisfies list on FEATURE-TRACE-EXPLORER-TUI would still be a comment.

It is useful to compare this to the way new concepts enter a long-running codebase generally. In my experience, typed abstractions rarely appear because someone sits down and designs them. They appear because the codebase crosses a structural boundary — a merge, a split, a rename, a language upgrade, a framework migration, a team handover — that forces previously-implicit knowledge to be explicit.

The carve-out is a canonical example. A package that lives inside its host repo can afford a great deal of implicit knowledge: conventions, folder structures, naming patterns, README sentences, chat-log decisions. A package that lives on its own has to export all of that knowledge as code, types, and tests, because the host repo is no longer there to carry the implicit part.

The act of exportation is the act of typing things that were previously untyped. Requirements were the most prominent untyped thing in this particular exportation.

The stratum was not designed; it was surfaced. That distinction is a recurring theme in the rest of the series — especially in the chapters on DDD revisited and on external vocabularies. The package the reader is about to walk through, chapter by chapter, is not the output of a top-down design session. It is the output of a series of small pressures that, cumulatively, turned implicit concepts into typed ones.

I will push the "surfaced" claim one more step, because it has an interesting corollary. If the stratum was surfaced rather than designed, then the stratum's shape is a reflection of the specific frictions the package encountered, not of any general theory of requirements.

That is a feature, not a bug: it means the package is calibrated to the kinds of projects that look like this one. It also means readers should be cautious about importing its shape wholesale into very different kinds of projects.

The five Styles are an example — they cover the registers I have personally met or read about in industrial, lean, agile, kanban, and ISO 29148 contexts. They do not cover legal drafting, scientific protocols, academic specifications, or military procurement. A project in one of those other registers would need to add a sixth Style, and the Style system is open-closed precisely so that adding one is a matter of implementing a handful of interfaces rather than forking the package.

The shape is calibrated, not universal. Calibration is the virtue the chapter is arguing for.

Phase 6 — what came first is still load-bearing

Requirement exists now. The stratum is typed, the decorators work, the compliance gate checks it, the scaffolder generates it, the trace-core walks it. Everything chapter 00 promised has a type behind it.

And yet: Feature is still the concept a newcomer touches first. That is not an accident of documentation. It is a structural fact about the code.

A developer adopting the package on a new project writes their first line of DSL code in a test file. They write @FeatureTest(Foo) above a class. They need a Foo class, so they create requirements/features/foo.ts and export an abstract class. They need acceptance criteria, so they write abstract methods.

At this point they have a working Feature and zero Requirements. The package's runtime registry accepts that state. The compliance gate, without the --strict flag, accepts that state. The scanner reports 100% coverage if every AC is verified. It is a minimal-ceremony setup, and it is correct.

This is where the design choice of keeping Feature as the first-touch concept pays off. A newcomer who has to declare both a Feature and a Requirement in their first session faces a forking question: which of these two do I start with? The answer has to be taught, either in documentation or in a mentor's voice.

A newcomer who has to declare only a Feature faces no forking question. They start with what is closest to the code they are already writing — the test. They decorate the test. They declare what the test is about. That is the whole first interaction with the DSL.

Requirement enters the picture later, when the newcomer writes their fourth or fifth Feature and notices that a common policy keeps re-appearing in their comments. The noticing is the moment the Requirement stratum earns its existence for that specific developer on that specific project. Not before.

The Requirement layer is read-when-needed, written-when-a-policy-emerges. If you looked at a random src/ file in the monorepo — not a requirements/ file; a real implementation file — you would see Feature references everywhere and Requirement references nowhere. Feature is the thing the implementation code points at. Requirement is what requirements/features/*.ts points at.

That asymmetry is by design; it reflects the local-to-a-test-file origin of Feature and the emerges-when-shared nature of Requirement.

I want to spell the asymmetry out in detail, because it has implications for how to read the rest of the series. A Feature, in practice, is referenced in three kinds of file: the requirements/features/*.ts file that declares it, the test files that decorate themselves with @FeatureTest(Foo), and — occasionally — the implementation file that the test exercises, if the implementation file imports the Feature class to check a runtime predicate. Three kinds of file.

A Requirement, in practice, is referenced in two kinds of file: the requirements/requirements/*.ts file that declares it, and the requirements/features/*.ts files that list it in their @Satisfies(...) arguments. Two kinds of file.

That difference — three kinds versus two — is small in absolute terms but large in what it implies about touch frequency. A Feature is touched whenever a test is added or modified, which in a healthy codebase is multiple times per week. A Requirement is touched only when a Feature's @Satisfies list changes, which happens whenever a Feature is created or whenever a policy is extracted from prose into a typed Requirement. That is much rarer.

A Requirement, once written, can sit untouched for months while the Features that satisfy it churn underneath. That stability is correct: a policy should change less often than the deliverables that obey it. The DSL respects this by making the Requirement layer a place where you write infrequently and carefully, in contrast to the Feature layer where you write frequently and lightly.

The Requirement is never referenced in test files. It is never referenced in implementation files. The implementation does not know which Requirements it indirectly satisfies, because the Requirement layer is, correctly, one abstraction level above the code. This is the right asymmetry. An implementation that knew about Requirements would be doing meta-level work in the domain layer, which is a category error.

To put it more concretely: in the current @frenchexdev/requirements codebase, there are twenty-five Feature subclasses and twenty-two Requirement subclasses.

Every Feature has a @Satisfies(...) list with between one and seven entries. The average Feature satisfies two Requirements. Some Requirements are satisfied by a single Feature; others are satisfied by seven or eight.

The ratio of Features to Requirements — roughly 1.1 to 1 — is a detail worth noticing. A naive reading of traceability literature might predict that a mature codebase would have many more Features than Requirements, on the assumption that each Requirement decomposes into several Features. That prediction is wrong for this package, and I think it is wrong for most real packages: Requirements do not decompose into Features in a one-to-many tree; they overlap with Features in a many-to-many graph.

A Feature typically satisfies two or three Requirements (because multiple policies apply to it) and a Requirement is typically satisfied by two or three Features (because multiple deliverables are responsible for upholding it). The graph is approximately symmetric. That symmetry is what @Satisfies encodes.

That is the final shape — but it is not the starting shape. The starting shape for a new project is: twenty Features satisfying zero Requirements, plus a list of half-formed policy ideas in the README.

The second shape, after a few weeks of real use, is: twenty Features satisfying three or four Requirements that got extracted along the way.

The third shape, after a year, might be: thirty Features satisfying twelve Requirements, some of which refine each other, some of which share a Style because the project went through a safety review.

Each shape is a valid stop on the ladder. A project is not "done" when it reaches the third shape. It is at the third shape because it ran into three-shape-sized problems. If your project never runs into those problems — if a handful of Features adequately carry your project's "why" — you stay at the first shape, and that is fine. The DSL does not punish you for it.

The "DSL does not punish you" claim is worth unpacking briefly, because the punishment-versus-reward framing is exactly where a lot of traceability tools go wrong. A tool that punishes you for not filling in the Requirement layer — by printing warnings, by failing compliance gates on orphan Features, by nagging the developer with "this Feature has no @Satisfies list" messages — is a tool that has confused the maximal use of its abstractions with the minimal responsible use.

The new package's compliance gate, without --strict, does not nag about missing @Satisfies lists. It does nag about uncovered ACs, because an uncovered AC is a Feature-level drift (the Feature promises a behaviour, no test verifies that behaviour, that is a gap).

It does not nag about Requirement-level gaps unless you have declared a Requirement with an approved status and no Feature satisfies it, because in that specific case the Requirement level is drifting from the project's own explicit commitments. The asymmetry is precise: drift within a declared layer is flagged; absence of a layer is not. That precision is what makes the DSL bearable for projects at the first shape.

This is the moral of the historical path. Feature first, Requirement later, is not a regret. It is the correct order. The correct order is driven by the fact that Feature is local and Requirement is shared, and that local precedes shared in every corner of software writing I have ever worked on.

A design that started with Requirement would have been out-of-order with the grain of how developers actually notice the need for abstractions. A design that adds Requirement only when the local Feature level starts to visibly repeat itself is in-order with that grain. The current package is in-order.

Diagram
Figure 01b.1 — Feature first, Requirement later. Four phases across roughly four years; the Requirement stratum is a late arrival.

The timeline flattens the history into phases, but the underlying grain is the same in every phase: whatever becomes easy to touch gets typed first; whatever becomes easy to share gets typed second. The C# lineage typed the touchable thing in 2022 and stopped. The TypeScript port typed the touchable thing in March 2026 and stopped. The refactor in April typed the shareable thing. Nothing in this progression was accelerated, and nothing was delayed.

A useful thought experiment: what would it have taken, in 2022, to jump directly to the current package's shape?

The answer is that no amount of foresight would have sufficed, because in 2022 there was no second package to be "parallel" to, no product surface generating cross-cutting prose, no safety-critical audience asking for an industrial register, no monorepo boundary forcing an explicit README.

The pressures did not exist. A design that anticipated pressures it had not yet felt would have been either over-fitted to imagined problems or under-fitted to the problems that actually arrived.

The historical sequence is not a story of discovery catching up to a pre-existing optimum; it is a story of design shapes that were locally correct at each step, each producing the frictions that justified the next step. The current shape is locally correct too. Something else will produce frictions the current shape does not resolve.

At that point there will be a Phase 7. This chapter is not a completion report.

I can name some of the candidate Phase 7 pressures, even though I have not felt them yet. The first is cross-package Requirements: as the monorepo grows, two packages may end up needing a shared Requirement that does not naturally live in either one. The second is temporal Requirements — rules whose validity is bounded by a date or a release cycle, the way regulatory deadlines work. The third is audience-specific Requirement variants, where the same underlying rule is stated differently for engineers, auditors, and salespeople.

None of those pressures has, today, become acute enough to justify a refactor. When they do, the package will gain whichever feature resolves them, and there will be a Phase 7 chapter, and the new chapter will probably argue, again, that the addition was surfaced rather than designed. That is the methodology this chapter is making the case for, and I expect to be using it again.

How the three pressures converged

The timeline shows when each phase arrived. It does not show how the three pressures of Phase 4 interacted. A concept-emergence diagram does that better than a timeline.

Diagram
Figure 01b.2 — Three pressures into one type. The Requirement stratum is the single type that satisfies all three use-site constraints simultaneously.

The diagram is the compressed version of Phase 4. Each of the three source nodes corresponds to one of the pressures described above. Each arrow into the decorator-use-site node is the failure mode of ignoring that pressure: cross-cutting policy becomes a fake Feature or silent prose drift; clustering becomes rationale repetition; register pluralism lands on the wrong class and contaminates every Feature.

The pivot at the decorator use site — what type do @Satisfies and @Refines expect? — is where the three pressures have to coexist.

A single output type has to absorb all three constraints. That type is Requirement. It has a Style. It refines other Requirements. It is satisfied by Features.

The stratum is emergent in the technical sense: no single pressure produced it; the convergence did.

This is a textbook case of the kind of emergence Herbert Simon describes in The Sciences of the Artificial — a design solution that appears, in retrospect, as the unique intersection of independent constraints. The Requirement stratum was not chosen out of a design space of alternatives; it was the only point in the space where all three constraints could be satisfied simultaneously. The appearance of choice is illusory; the intersection was forced by the geometry of the constraints.

Naming the convergence as emergent is not a rhetorical flourish. It has practical implications for how to think about extending the package in future. If a new pressure arises that cannot be absorbed into the existing Requirement stratum, the right response will not be to stretch the stratum; it will be to look for the next intersection, the one the new pressure shares with the existing pressures, and to introduce whatever concept lives there. That concept might be a third M2 type, or a refinement of an existing one, or a parameter on Style, or something not yet imagined. The methodology is: follow the pressures; let the convergence tell you what to build.

I want to flag, briefly, that this is the same shape of argument as the Liskov or DDD arguments for introducing an abstraction. You do not add a concept because a design manual tells you to. You add it because two or three pieces of code, independently, start needing something that does not exist, and the cheapest way to satisfy them simultaneously is to create the missing concept.

That is the posture this package was built with. It is the posture I would recommend for anyone adopting the pattern: introduce Requirements when the three pressures — or two of them, or even one of them if it is severe enough — have become impossible to ignore.

There is a separate point worth making about the order in which the pressures tend to arrive. In my experience, cross-cutting policy shows up first — as soon as a project has more than four or five Features with any shared infrastructure, at least one policy will be implicit in multiple places.

Type pressure from the use site shows up the moment someone tries to write @Satisfies with a well-typed argument list. Clustering shows up around the tenth Requirement — below ten, the Requirements are usually independent enough that refinement is not needed. Register pluralism shows up when the project intersects an external audience: a safety reviewer, a product owner, a customer in a different industry.

If you are watching for these pressures, you will feel them in roughly this order, with the first two arriving close together and the last two following months later. A project that has only felt the first two pressures can live comfortably in a flat Requirement space without @Refines and without a Style system. Adding either of those prematurely is a common over-engineering failure. The DSL's architecture does not require them; they are opt-in.

Why this history matters for the reader

Two takeaways for a reader who is deciding whether to adopt this DSL.

Takeaway one — starting from scratch

The first takeaway is for new projects. If you are using the DSL on a new project, start with Features. Write one requirements/features/foo.ts file. Write one test/foo.spec.ts file. Decorate with @FeatureTest and @Verifies. Run requirements compliance. Watch it pass. Do this for three or four Features before you even consider adding a Requirement.

You will notice, around the third or fourth Feature, that certain phrases keep showing up in your prose: "this must be observable", "this must be refactor-safe", "this must integrate with our staging pipeline". Those phrases are proto-Requirements. They are not Requirements yet.

They become Requirements when the same phrase is load-bearing across more than one Feature and deleting it from any one Feature would quietly break something. At that point — and not before — you promote the phrase to a Requirement class and add it to the relevant Features' @Satisfies lists. Do not try to design a Requirement tree upfront. That is premature. The top-down Requirement tree is the thing this history is arguing against.

I will give a concrete heuristic, since "notice when a phrase repeats" is vague. If you find yourself copy-pasting a sentence from one Feature's comment block to another Feature's comment block — or worse, retyping it slightly differently because you forgot the exact wording — that is the signal.

The repeated sentence is the proto-Requirement. The next time you see it, promote it. Writing a Requirement subclass takes about five minutes with the requirements requirement new wizard. The cost of the promotion is small; the cost of ignoring the signal is the drift this chapter is about.

Takeaway two — porting from typed-specs

The second takeaway is for readers porting from typed-specs. You do not need to retrofit Requirements to every Feature immediately. The @Satisfies list can start empty. The decorator has a well-defined behaviour at zero arguments: the Feature is still registered, the compliance gate still counts its ACs, --strict still fires on uncovered Features, and nothing else changes.

You can migrate one Feature at a time as policies materialise. A Feature without a @Satisfies list is a Feature whose "why" has not yet been extracted into a Requirement. That is fine. You will extract it when the third pressure shows up, and not before.

If the third pressure never shows up for a given Feature, the Feature can live without a @Satisfies link for its entire lifetime. Some Features are their own why.

A port strategy that works well in practice: start by changing the base class import from typed-specs's Feature to @frenchexdev/requirements's Feature, and the decorator imports from @Implements to @Verifies.

That is a mechanical transformation — a single global find-and-replace in most projects.

If your project has custom decorators built on top of typed-specs — a domain-specific wrapper around @Implements, say — those will need manual translation, but the underlying pattern is the same: the decorator takes a keyed class reference and checks at compile time that the key exists. The grammar is identical. The names are different.

Run the compliance gate. Watch it pass. Your project is now a valid @frenchexdev/requirements consumer, with zero Requirements declared. Every Feature is still exactly what it was in typed-specs. The upgrade has bought you the option of declaring Requirements, without yet having spent the effort of doing so. From there, introduce Requirements opportunistically, as the frictions this chapter describes surface in your own codebase.

When not to adopt

The "When NOT to use this DSL" section in chapter 19 elaborates on these defaults. If your project is small enough that Features carry the whole why, you may not need Requirements at all, and the package's Feature-only mode is exactly the subset of the DSL you want. The history in this chapter is the theoretical justification for that subset being a first-class mode of the DSL rather than a degenerate case.

The corollary, worth naming, is that a project adopting this DSL can live at any of the three shapes from Phase 6 — twenty Features and zero Requirements, twenty Features and four Requirements, thirty Features and twelve refining Requirements — and none of those shapes is a regression or a failure.

Each shape is a valid stop because the grain of the DSL supports stopping there. Projects move along the shapes because they run into shape-sized problems, and they stop when they stop running into them. This matters for adoption calculus: the DSL is not asking you to front-load a full traceability ontology. It is asking you to start where you are and add what you need.

One last implication, which I will state because it is easy to miss. A project that stops at the first shape — Features only, no Requirements — is not a project that has failed to adopt the DSL properly. It is a project that does not have the kinds of pressure that would justify the second stratum.

The DSL is designed so that the absence of Requirements is a valid state of the code, not a transitional state. Compliance reports still render. The trace-core still walks the graph. The scaffolder still generates test files. Everything works at the first shape because the first shape was the only shape for a year, and the package still supports that configuration as a first-class mode.

Reading the rest of this series, it is easy to come away thinking that the full twenty-two-Requirement configuration is the "real" use of the DSL and that anything less is compromised. That is not true. The full configuration is what this specific package needed. Yours may need less. The DSL is calibrated to support less as a stable end state, not only as a waypoint.

Running-example recap

FEATURE-TRACE-EXPLORER-TUI, as it exists in the package today, has three Requirements in its @Satisfies list: ReqDiscoverableTraceabilityRequirement, ReqDogFoodRequirement, and ReqParallelDeliverableRequirement. It did not start that way.

Six weeks ago, when the Feature was first sketched, its file contained a prose comment that said "this is the interactive trace browser; it should dog-food the DSL and be explorable without prior knowledge of the vocabulary" and a list of abstract methods. That comment carried the three Requirements in one sentence. The sentence was not wrong; it was just not typed.

The discoverable-traceability Requirement materialised when multiple Features — trace-core, the TUI, the mermaid-graph renderer — needed to point at the same "humans walk this graph" rule. Before the rule existed as a Requirement, each of those Features had a slightly different phrasing of the same idea in its prose. The rule was implicit and triply stated.

Extracting it into REQ-DISCOVERABLE-TRACEABILITY collapsed three prose restatements into three @Satisfies references. The next time that rule changes — and it will change, the first time a user actually tries the TUI and finds a navigation path they did not expect — it will change in one place, not three.

The dog-food Requirement materialised by the same route, earlier, for a different set of Features. The first Features to claim @Satisfies(ReqDogFoodRequirement) were the compliance scanner and the scaffolder — the two pieces of infrastructure whose correctness the dog-food policy most directly constrains.

The decorator appeared on those two Features before the Requirement had a name; it appeared as an argument-less note, then as a string comment, then as an imported class reference, over the course of about a week. By the time the Requirement was named ReqDogFoodRequirement and given its current rationale and fit criteria, the two satisfying Features had been waiting for it for several days.

The Requirement was, in that specific sense, back-formed from the Features that were going to satisfy it. This is another instance of the same pattern: the shared thing crystallises after the local things that need to share it.

The parallel-deliverable Requirement materialised during the monorepo extraction, specifically to capture the policy that this package and @frenchexdev/typed-fsm should be usable independently of each other without a hidden dependency on the CV demonstrator. That one is the clearest example of an extraction-caused Requirement.

Before the carve-out, the policy did not exist — there was no separate @frenchexdev/typed-fsm for it to apply to, because the FSM code was still inside the CV repo. The carve-out created two packages where there had been one, and the two packages immediately needed a rule about their mutual independence.

The rule was a Requirement. The Requirement was added.

FEATURE-TRACE-EXPLORER-TUI — which happens to use the FSM package for its interaction state — picked up @Satisfies(ReqParallelDeliverableRequirement) in the same commit that the Requirement was introduced. No design session produced this; the carve-out produced it, and the Feature's @Satisfies list grew to match.

The commit message for that change, in the git log, is short. It says something like "carve-out: extract parallel-deliverable policy as typed Requirement; bind from TUI." The commit is the smallest possible unit of Phase 5 in action: a pressure (the carve-out) produced a sentence (the policy), the sentence became a class (the Requirement), the class found its satisfiers (the TUI and two others), and the @Satisfies list on each satisfying Feature grew by one entry. That is the whole motion, visible in one commit.

A reader interested in the methodological details of this chapter can, in principle, read the git log of the packages/requirements/ directory and reconstruct every such moment. I will not do that here — the log is long, and reconstructing it would produce a different kind of chapter — but the invitation is real. Every Requirement in the package has a commit like the one just described, and the commit tells the small story of how the Requirement came to exist.

Three Requirements, three extraction moments, zero upfront design. That is the concrete shape of the emergence this chapter has described.

Every @Satisfies arrow in the current package has a similar small story behind it. None of them were invented at the whiteboard; all of them were extracted at the keyboard, when the pressure became real enough to act on.

If you trace any single @Satisfies(...) argument in the codebase back to its first appearance in git history, you will find a commit that is doing some other, smaller piece of work — adding a Feature, fixing a bug, refactoring a file — and the @Satisfies reference is a side effect of the smaller work. The Requirement was extracted because the smaller work made the Feature's "why" explicit enough to deserve its own type.

That is the keyboard-level instinct the chapter has been trying to make visible. It is not a methodology you can teach by writing it down as a methodology. It is a habit you acquire by writing enough code in the pattern that the moments-of-extraction become recognisable in your own typing.

One detail about the Feature's @Satisfies list is worth flagging as a teaching point. The three Requirements it satisfies do not form a tree — they are not refining each other, and they are not ranked by priority within the Feature. They are a set.

A Feature that satisfies three Requirements is satisfying them in parallel, not hierarchically. If two of them ever come into conflict — if, for example, the dog-food policy says "the test must exercise this code path" and the parallel-deliverable policy says "the test must not depend on the other package" — the Feature's implementation is responsible for resolving the conflict, not the @Satisfies decorator.

The decorator records the satisfaction relation; it does not record the resolution. Conflict resolution between Requirements is a design question that happens at the Feature level, which is where the designer who writes the Feature can see all the Requirements together. This is, again, an instance of Feature-level locality doing work that the Requirement level cannot: the Feature knows its context; the Requirements do not know each other.

The rest of the series unpacks the decorators, the Styles, the compliance gate, the scaffolder, and the meta-circle. This chapter's job was to make clear that the whole thing rests on a history, not on a design session — and that the history has a grain you can use when you adopt the pattern yourself.

Feature first, Requirement later. Local first, shared later. Touchable first, abstract later.

That is the grain. The DSL follows it, and so should your adoption of it.

What this history excludes

A few words on what this chapter does not claim, because the omissions are easier to see when they are stated explicitly than when they are simply left out.

The chapter does not claim that every project will need a Requirement stratum. Many will not. A solo project, a small product team with a coherent vision, a research codebase that is exploratory by design — any of these may live productively at Phase 2 forever. The chapter argues only that if the three pressures of Phase 4 arrive, then the second M2 type becomes the cheapest available response.

The chapter does not claim that this DSL is the right tool for every project that does feel those pressures. Other tools — DOORS, Polarion, Jama, custom in-house systems — exist, are mature, and serve their audiences well. The argument here is not for one tool over another; it is for a particular philosophy of how typed abstractions ought to enter a codebase. The philosophy travels; the implementation is one of many possible.

The chapter does not claim that the Requirement stratum, once introduced, is finished evolving. The Style system already implies that the stratum is open at the type level — new Styles can be added by consumers — and the same kind of openness applies to Scaffolders, Validators, and Reporters. The chapter has stopped at Phase 6, which is the current state of the package, but Phase 6 is not a steady state. It is a snapshot.

The chapter does not claim that the order of phases would be the same in another team's history. A team that started with a multi-package monorepo from day one might encounter Phase 5 before Phase 4. A team that adopted an industrial style from the start might encounter Phase 4's register-pluralism pressure before any of the others. The phases described here are the order this particular history produced; they are not a universal sequence.

An alternative history we did not live

It is worth imagining, briefly, the counterfactual. What if the design had started from Requirements instead of from Features? What if, in 2022, the C# side-project had begun with a [Requirement] attribute and let Features emerge later?

That history would have looked quite different, and not in a good way. A Requirement, at the 2022 level of the project, would have been a sentence about what the code should do — something like "navigation must be accessible", attached to a top-level class. The next question would have been: how does the compiler check whether navigation is accessible? And the answer would have been: it does not, because a Requirement is a claim, not an observation.

To make the claim checkable, the project would have needed a second level — something like a Feature — that decomposed the Requirement into observable behaviours. But that second level is exactly what the Feature-first approach produced, in reverse order. The counterfactual arrives at the same two-level ontology by the long route.

The difference is pedagogical, not structural. Starting from Requirements forces the designer to think top-down: here is the policy, decompose it. Starting from Features lets the designer think bottom-up: here is the test, write down what it is about. The bottom-up order is, in my experience, how developers actually think when they are writing code. Top-down is how managers think when they are planning code.

A tool that enforces top-down thinking will be rejected by the people actually typing the characters. A tool that enforces bottom-up thinking will be adopted. This is, I think, the deepest reason the Feature-first order is not just historically accidental but methodologically correct: it matches the grain of developer practice.

There is a secondary benefit worth naming. A Feature-first pattern is adoption-friendly in a way a Requirement-first pattern cannot be. A developer who sees @FeatureTest(Foo) on a test method can mimic the pattern without committing to anything upstream. A developer who sees @Satisfies(ReqFoo) on a class has to first understand what ReqFoo is, where it lives, what it means, and how it relates to Features — which is four concepts before any code gets written. The adoption cost of the two orders is not comparable.

A philosophical aside on the methodology

I have used the word grain repeatedly in this chapter, and the word surface almost as often. Both are doing real work. Before closing, I want to say something explicit about why I keep choosing those metaphors over alternatives.

A grain, in the woodworker's sense, is a property of the material that constrains what tools can be used and in what direction. A grain is not a rule the woodworker chose; it is a fact about the wood. A skilled woodworker reads the grain and adapts; an unskilled one fights it and produces splinters. The metaphor maps cleanly onto codebases: the existing structure of a codebase is a fact about that codebase, not a rule its author chose, and the right way to add abstractions is to read what is already there and add along the existing direction.

A surface, in the geological sense, is what is exposed when something deeper is uncovered. Geologists do not invent the strata they map; they trace surfaces that the rock has revealed through erosion or excavation. The metaphor maps onto the Requirement stratum: the stratum was not invented by a designer; it was uncovered by the act of carving the package out of its host repo, the way an archaeological excavation uncovers a wall by removing the dirt above it.

Both metaphors share a posture: the designer is responding to the material, not imposing a shape on it. The alternative metaphors — architecting, modelling, engineering — all imply that the designer brings a pre-existing shape to the codebase and then bends the codebase to fit the shape. That is the posture this chapter is arguing against.

A codebase that has been bent to fit a pre-existing shape will, in my experience, fight every subsequent attempt to evolve it, because the shape was not its own. A codebase that has been shaped by its own pressures, on the other hand, evolves easily, because each new shape grows out of the last one. The grain stays continuous. The strata stay legible.

This is why I keep insisting that the Requirement stratum was surfaced rather than designed. It is not false modesty. It is a precise claim about the methodology the chapter is recommending.

A reading guide for the chapters that follow

The next several chapters of this series unpack the Requirement stratum from different angles. A short reading guide may help the reader pick which to read first.

Chapter 02 — Why dog-food a requirements DSL is the chapter that promotes the dog-food rule from a project policy to a first-class Requirement. It is the most direct continuation of this chapter's argument: where this chapter explained why Requirements need to exist at all, chapter 02 explains why one specific Requirement — the dog-food one — has to exist for this package specifically.

The chapters on REQ-DOG-FOOD's decomposition (chapters 03 through 05 in the series) walk through the satisfaction relation in detail. They are good follow-ups for a reader who wants to see the @Satisfies decorator in concrete use, with several real Features each pointing at the same Requirement.

The chapter on Styles (chapter 09 in the series) returns to the register-pluralism pressure named here as the fourth Phase 4 pressure. It walks through the five built-in Styles in detail and shows how a sixth Style would be added.

The chapter on the meta-circle (chapter 12) returns to the dog-food rule from a different angle: not as a policy that constrains the package, but as a mathematical property of the package's own self-application. It is the most abstract chapter in the series and probably the most surprising.

The chapter on lessons and anti-patterns (chapter 19) is the chapter to read if you want a checklist of what to do and what not to do. It is also the chapter that names the situations where this DSL is the wrong tool.

A reader with limited time should read chapter 02 next. A reader with more time should read chapters 03 through 05 to see the decorators in action. A reader who wants the full theoretical arc should read in order.

A reader who arrived at this chapter after already reading chapter 00 is in the best position to appreciate what follows: the gap named in chapter 00 has been historically situated here, and the rest of the series will close it, concept by concept. A reader who has not yet read chapter 00 may find that chapter a useful companion to this one; the two chapters are designed to be read together, synchronic and diachronic readings of the same gap.

Acknowledging adjacent lineages

A final piece of context before the grain note. The approach this chapter describes is not unique to the @frenchexdev/* monorepo. Other ecosystems have arrived at similar two-level ontologies through different historical paths.

SysML, standardised by the OMG, has had a Requirement block and a satisfy relation for close to two decades. The SysML ontology is considerably more elaborate than what this package offers — it distinguishes derive, refine, trace, verify, and satisfy as separate relations — but the underlying pattern is the same: Requirements are their own modelling kind, distinct from the deliverables that satisfy them, and the relation between them is explicit.

DOORS and its descendants encode Requirements as first-class objects in a database, with satisfaction relations as first-class links. The tooling is mature, the audiences are large, and the patterns are well understood. What those tools do not offer — and what this package offers instead — is the compile-time checking of the satisfaction relation. In DOORS, a requirement satisfies another requirement because a link in the database says so; in this package, a Feature satisfies a Requirement because the compiler accepts the @Satisfies(ReqFoo) decorator only if ReqFoo is a valid class reference. The checking layer is different, and the shift from database links to compile-time references is the shift this package is built around.

The academic literature on requirements engineering — Michael Jackson's problem frames, Axel van Lamsweerde's goal-oriented requirements engineering, Martin Glinz's work on non-functional requirements — all predate typed specifications and all inform the shape this package lands on. I have not cited them in the chapter because the chapter is about a historical path rather than a literature review, but the debt is real.

The point of acknowledging these adjacent lineages is to frame the historical path described in this chapter as one path among many. The Requirement stratum did not emerge in a vacuum. It emerged in conversation with a long tradition of requirements-modelling thought, even when — as was the case for most of Phase 1 through Phase 3 — the designer had not read that tradition closely. The tradition informed the shape anyway, through the textbooks, articles, and tools that the wider software culture had absorbed and re-emitted.

A reader who wants to situate this package in that wider culture will find the external-vocabularies chapter (chapter 19b of this series) a useful next step. It maps the package's vocabulary to the SysML, DOORS, and academic vocabularies directly.

A last note on the grain

Two more sentences, because the word grain is load-bearing in this chapter and I want to leave the reader with a crisp version of what it means.

I have used the word often enough that a reader might reasonably worry it is doing more metaphorical work than definitional work. Before the closing lines, then, a direct definition.

Grain here refers to the direction along which a material yields easily under stress and the direction along which it resists. Wood has grain. So does code.

When you cut wood against the grain, you get splinters; when you cut with the grain, you get clean edges.

When you add abstractions against the grain of a codebase, you get ontologies that have to be taught and maintained against the rest of the code's gravity; when you add them with the grain, the abstractions feel obvious in retrospect and they stay obvious without maintenance.

The grain-feeling-obvious-in-retrospect is a specific phenomenon worth naming. When you read a well-factored codebase, you often have the sensation that the abstractions are the only ones that could have been chosen. That sensation is the grain showing itself. The abstractions were not the only possible ones; they are the ones that aligned with the material's existing stresses.

A codebase whose abstractions feel inevitable in retrospect is a codebase whose author cut with the grain. That is the highest compliment I know how to pay a piece of software.

The grain of this particular codebase runs from local to shared, from test file to feature file to requirement file, from the keyboard to the type system.

Typed-specs' Feature class was carved with that grain. The new package's Requirement class was carved with that grain, months later, when the material was ready for the second cut.

The chapter is an argument that cutting in that order was not a compromise or a concession to pedagogy; it was the order the grain demanded. A different grain would have produced a different history, and a different package.

This package is this package because this codebase had this grain.

The codebase is itself a peculiar codebase — a terminal-styled CV that grew into a blog that grew into a test-bed for a DSL — and the grain reflects that peculiarity. A codebase with a different history would have a different grain. A team with a different composition would read the same material and cut differently.

There is no universal grain. There is only the grain of this material, in this moment, under this designer's hands. The methodological claim of the chapter is not that every codebase has the same grain as this one; it is that every codebase has a grain, that reading the grain is the right first move, and that the abstractions you add should follow what the reading tells you.

Readers adopting the pattern are cutting different wood. The direction of their grain will match, to a first approximation, the direction of this one — Feature local, Requirement shared, observable first, policy second — but the specific stopping point will be their own.

That is the thing to take away.

The six phases of this chapter are not prescriptions; they are examples of what cutting with the grain looked like in one project. Your project will have its own six phases, not all of which you will recognise until you are past them.

A last and very concrete piece of advice, because the chapter has been long on history and maybe short on actionable guidance. If you are adopting this DSL on a new codebase, do not try to plan the Requirement stratum in advance. Write Features. Keep writing Features.

When you notice that a phrase keeps showing up in your Feature comments — a policy, a rule, a rationale that travels from one Feature to the next — promote that phrase to a Requirement. Use the requirements requirement new wizard. Give the Requirement a name. Bind it from the Features that were carrying the phrase.

The binding is a one-line edit per Feature: add an entry to the @Satisfies(...) decorator. The compliance gate will accept the change immediately. The next time you run the scanner, the new Requirement will show up in the coverage matrix with its satisfier set. You have grown the ontology by one concept, in situ, in response to a real pressure. That is the methodology. Repeat as often as the codebase pushes you to.

One more thought on timing

Before the closing grain note, one observation about timing that has been implicit throughout and deserves a direct statement.

The Phase 4 refactor took about two weeks of calendar time and maybe five hours of focused work distributed across those two weeks. The Phase 5 extraction — the full monorepo carve-out — took longer, but only a fraction of that time was spent on the Requirement stratum; most of it went into build configuration, package interdependency resolution, and CI pipelines. The Requirement stratum itself, as a unit of work, was small.

This matters because it corrects a common anticipation. Developers who hear the phrase "we added a Requirement layer to our type system" often imagine a multi-week refactor involving substantial code reshaping. In practice, if the preconditions are right — a flat M2, a decorator-based DSL, a clear pressure — adding the second stratum is a small move. The reason it is a small move is exactly the reason it was delayed for so long: there is very little code to write, and very much prose to reconsider. The work is cognitive, not mechanical.

The second stratum earns its presence by the cognitive clarity it produces, not by the lines of code it adds. That is another way of saying the grain was already pointing toward it; the refactor was the moment the grain was noticed, not the moment the material was reshaped.

A note on who this history is for

This chapter has been written in an unusual register for a technical blog. It reads more like an autobiographical reflection on a design process than like documentation. That register was deliberate.

The audience for this chapter is a developer who is considering whether to adopt this DSL, or a similar one, in their own project — and who wants to know not how the DSL works (that is the rest of the series) but whether the philosophy behind it matches the philosophy their project needs. A philosophy match is hard to establish through feature lists or API references; it is easier to establish through a history.

If you read this chapter and found yourself thinking "yes, that is how abstractions have entered my codebases too", the philosophy probably matches. If you read this chapter and found yourself thinking "that is not how we plan things here — we decide the ontology first and implement afterwards", the philosophy probably does not match, and you will be happier with a tool that enforces a top-down ontology from the outset.

Neither posture is wrong. They suit different project cultures. The honest answer to "should I adopt this DSL?" starts with the question of which culture you are in, not with the question of which features the DSL has.

  • typed-specs/01-why.md — the origin-story the TypeScript lineage grew out of. Read this to see the shape of the Phase 2 design in the author's own words of the time.
  • feature-tracking-ts.md — the TS-side bridge from the C# lineage. The post that made the port explicit before the typed-specs series gave it a seven-part home.
  • typed-specs/04-features.md — the phase-2 Feature type, in full. The chapter a new reader should compare against the current package's Feature class to see what stayed and what grew.
  • Chapter 00 — Named but Not Modelled — the gap this history produced. Chapter 00 is the synchronic close reading; this chapter is the diachronic one. Read them together.
  • Chapter 02 — Why dog-food a requirements DSL — the axiom of the new package. Takes the refactor pressure described above and promotes it to REQ-DOG-FOOD as a first-class Requirement.

Previous: 01 — From typed-specs to typed-requirements · Next: 02 — Why dog-food a requirements DSL

⬇ Download