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 22e — Schema Evolution: Versioning the DSL Itself

The spec evolves. The language the spec is written in evolves. These are not the same change.

The previous chapter in this series was about versioning specs — individual Requirements, the VersionInfo.version integer that bumps when a statement is edited, the contentHash that makes the bump idempotent and cosmetic-change-immune, the pin table that lets Features freeze against a specific (version, hash) pair so that a req-evolved drift becomes a compliance gate failure rather than a silent semantic drift. Every mechanism in that chapter lived inside a single run of the DSL, at a single point in its own history. The vocabulary of statuses was fixed. The set of valid requirementKind values was fixed. The shape of RequirementSource was fixed. What moved was the author's intent, recorded in the statement and fitCriteria and rationale fields of one particular Requirement.

This chapter widens the frame by one turn of the screw. Here we version the language those specs are written in. The set of valid requirementKind values is no longer fixed: it is a property of whichever Style a project has wired up, and Styles themselves change over time. The shape of VersionedSpec<S> is no longer fixed: somebody may, between one release of @frenchexdev/requirements and the next, add an optional field, narrow a slot type, or restructure the history entry. The wire format of requirements-bindings.json is no longer fixed: a breaking change to RequirementSource's discriminator values makes old JSON unreadable by new code. The discipline that governs these moves is the subject of the chapter. The question the discipline has to answer is not "did this Requirement change?" but "did the grammar of Requirements change?" and, if so, "what does every existing Requirement, pin, binding, and report have to do to survive the change?".

The package already has the primitives. packages/requirements/src/cli/versioning.ts exports export const SCHEMA_VERSION = '2026-04-14' — or, more precisely, the date-stamped string travels on every VersionedSpec<S> as the optional $schemaVersion field, is stripped from the canonical form before hashing (so that a schema bump does not spuriously re-version every existing Requirement), and is re-emitted on every serialized artifact the CLI writes. packages/requirements/src/style.ts exports RequirementStyle, StyleValidators, StyleTemplates, and a set of type extractors — KindsOf<S>, StatusesOf<S>, SourceKindsOf<S> — that pull a literal string-union out of a const-typed Style so that Requirement<S> can narrow kind to exactly the vocabulary the project recognises. Those two files together encode both kinds of version: the runtime, serialization-observable one ($schemaVersion) and the compile-time, erased-after-typecheck one (the Style itself). The distinction is easy to miss because both travel on the same class. This chapter is the place where the distinction earns its weight.

Two kinds of version

The first thing to get right is that a Requirements DSL has two independent axes along which it can break. A language bump on one axis can leave the other untouched; a careful design will make additive motion on both the default path and treat breakage as a rare, announced event.

Diagram

The wire-format version is the one a future archaeologist would see. Somebody opens requirements-bindings.json in 2031, sees "$schemaVersion": "2026-04-14", looks up the schema registry for that date, and reads the file under the grammar that was valid when the file was written. The stamp tells them which JSON Schema to validate against, which optional fields to expect, which discriminator values to enumerate in a switch statement. It is a key into a historical registry of wire formats, and the value it carries — a date string — is intentionally chosen to sort well in filenames, to avoid semver-purity arguments, and to make the when of the break self-documenting. We defend the choice in the next section.

The Style version is the one that only matters while TypeScript is checking the code. If a project imports DefaultStyle from @frenchexdev/requirements/styles/default and declares class MyReq extends Requirement<typeof DefaultStyle> { kind = 'Functional' as const; … }, the 'Functional' literal is type-checked against KindsOf<typeof DefaultStyle>, which is — because of the const assertion on DefaultStyle.vocabulary.requirementKinds — the union 'Functional' | 'NonFunctional' | 'Constraint' | 'Compliance' | 'UserStory'. Change the array. Re-run tsc. If the old kind = 'UserStory' is no longer in the union, the compile fails. That is the entire versioning mechanism on this axis. There is no runtime check. TypeScript's type erasure means that, at runtime, the serialized spec carries a plain string; it's the compiler, at the authoring site, that refuses to let an author write a string that the current Style does not recognise. The Style never appears in the JSON. The Style is a contract between the author and the type system, not between the producer and a future consumer.

These two axes are independent. A project can add a new requirementKind to its Style — widening KindsOf<S> by one literal — without touching $schemaVersion. Conversely, a schema bump could introduce a new optional field on VersionedSpec<S> without changing any Style vocabulary. Most actual evolution happens on the Style axis: vocabularies accrete organically, one review meeting at a time, as the team discovers that their projects need a new rationale kind or a new intermediate workflow state. Wire-format bumps are rare events — the kind of thing that happens when the underlying JSON Schema genuinely has to re-shape, when a field has to be renamed at the serialized level, when a discriminator value has to be removed.

The confusion to avoid is treating them as the same thing. Early versions of the package did treat them as the same thing: a single integer version that bumped whenever either axis changed. The result was that adding a new EARS-variant statementPattern — a backwards-compatible, additive motion — triggered a full wire-format re-stamp across every artifact, which in turn triggered re-downloads from every downstream consumer, which in turn prompted angry issues asking why an innocuous vocabulary addition broke deployment pipelines. Splitting the axes was the fix. The Style axis absorbs the common case (vocabulary evolution) without observable churn. The wire-format axis absorbs the rare case (structural reshaping) with a loud, dated, announced event.

A useful way to internalise the split is to map each axis to the question it answers for the reader. The wire-format version answers how do I parse this bytes-on-disk artifact?; the reader is a consumer who has in hand a JSON file written at some unknown point in the past and needs to reconstruct the object model. The Style version answers which vocabulary was the author authorised to write under?; the reader is typically a human reviewer or a type-checker auditing whether a Requirement's declared kind is one the project recognises. Two different readers, two different moments, two different failure modes. A mismatch on the wire axis produces a parser error — a JSON.parse that returns malformed data, a Zod or Ajv validator that rejects the payload, a discriminated-union switch that hits its default branch and throws. A mismatch on the Style axis produces a compile-time type error — Type '"OldKind"' is not assignable to type 'KindsOf<MyStyle>'. Parser errors happen at runtime, to consumers. Type errors happen at build time, to authors. Keeping the two separate keeps the error messages legible to the right audience.

There is a third thing that travels alongside the two versions, and it is worth naming even though the rest of the chapter will mostly leave it in the background: the contentHash from the previous chapter. The hash is per-spec, per-revision, and is computed on a canonical form that strips the wire-format stamp (via the NON_STRUCTURAL_FIELDS list in versioning.ts, line 74) precisely so that a schema bump does not spuriously re-hash every Requirement in the repository. The three axes stack cleanly: the $schemaVersion says which grammar; the Style says which vocabulary within that grammar; the contentHash says which particular instance within that vocabulary. A well-designed migration changes one of the three at a time. A poorly-designed migration conflates them, which is how you end up with every pin in the pin table being simultaneously stale and every re-hash triggering a downstream pin reconciliation and every reviewer asking what actually changed. The discipline this chapter describes is, in part, the discipline of changing one axis at a time and being explicit about which one.

Why $schemaVersion = '2026-04-14', not 1.0.0

The package already uses a date literal, and the choice deserves a defence because it is the first thing any reviewer familiar with semver flags as a mistake.

Semver answers a particular question: is this upgrade going to break my code? It is a contract between a library's author and its callers. A bump from 1.2.3 to 1.2.4 promises no observable behavioural change to the public API; 1.2.3 to 1.3.0 promises additive changes only; 1.2.3 to 2.0.0 is the announcement that the caller's old code will not work against the new library. The contract is useful because library upgrades are constant, callers run old code against new interfaces all the time, and the reader of the version number has to decide quickly whether to wrap the update in a compatibility layer or hope for the best. Semver compresses that decision into three integers.

Schema versions answer a different question: what vocabulary did the author of this artifact know?. When I read a requirements-bindings.json file written three years ago, I am not asking whether my code will break — I know my code will break, because the library has moved on. I am asking what grammar to interpret the file under. Which fields existed in 2023? Which kind values were valid then? Which statementPattern discriminators did the EARS vocabulary recognise? The answer is archival, not behavioural. It is a key into a registry of historical grammars, not a warning about compatibility.

Date-stamps are the right shape for that question. '2026-04-14' is unambiguous in a way that 1.0.0 is not — there is no debate about whether the break was a major version bump or a minor one, no counting of integers, no re-reading of a CHANGELOG to figure out whether one particular breaking change crossed the line. The date is the line. It is also unambiguous across projects: the schema registry for the @frenchexdev/requirements package, the one for Kubernetes' apiVersion, and the one for CloudEvents all use dates, and a reader jumping between artifacts from different ecosystems can compare them by simple string sort. '2026-04-14' < '2026-07-01' < '2027-01-15'; no imagination required.

They also sort naturally in filenames. If at some future point the package needs to ship a schemas/ directory with one JSON Schema per schema version — and it will, the moment more than one wire format is in circulation — the filenames requirement-spec.2026-04-14.schema.json, requirement-spec.2026-07-01.schema.json, requirement-spec.2027-01-15.schema.json line up in alphabetical order, which is chronological order. A ls -1 listing is a history. Compare the same directory under semver: requirement-spec.1.0.0.schema.json, requirement-spec.1.1.0.schema.json, requirement-spec.2.0.0.schema.json — the file names sort alphabetically (1.0.0 < 1.1.0 < 2.0.0 works for single-digit majors, but 10.0.0 < 2.0.0 lexicographically once you cross the ten-barrier). The date is robust where semver is fragile.

The third argument is the strongest: dates document when the break happened. A reader of 2026-04-14 knows, without any further context, that this is roughly the schema of mid-April 2026. They can check git log for that date, look up the CHANGELOG entry, see what else was happening in the package's history. A reader of 1.0.0 has no temporal anchor. The version number is opaque to anyone not holding the library's VERSION file in the same tab. Dates are legible without context. They carry their own provenance.

The precedents are real and widely adopted. CloudEvents uses specversion: '1.0' at the API layer but dates its schema artifacts. Kubernetes uses apiVersion strings like v1, v1beta1, apps/v1 — a hybrid of generation ordinals and stability suffixes, which is not quite date-based but shares the property that the version is a label for the grammar, not a semver-style compatibility promise. Catala — the French legal-rule DSL we cite throughout this series — ships annual releases and identifies them by year; the catala-0.9.0 release of 2024 and the catala-1.0.0 release of 2025 are distinguished in practice by the calendar, not by the semver digits. Stripe's dated API versions — 2023-10-16, 2024-04-10, and so on — are the industry-reference case, in use for over a decade, specifically because Stripe's engineering team came to the same conclusion: semver cannot carry the archival weight a long-lived wire format requires.

A final argument, more cultural than technical: date-stamps don't invite semver-purity arguments. Somebody on the team will always want to debate whether a particular change "counts as a major bump", whether adding an optional field is strictly 1.x → 2.0 or merely 1.2 → 1.3, whether renaming a discriminator is a minor change with a migration helper or a major change full stop. The debates are unproductive because semver was designed for a different question; it does not have a crisp answer for archival changes. Dates cut the debate. The answer to "should this be a bump?" is always either we ship a new date or we don't, and the answer to "which date?" is today's. Simpler, stricter, less bikesheddable.

The one genuine objection to date-based versioning is that it does not encode compatibility information. A reader of 2026-04-14 versus 2026-07-01 cannot tell, without consulting the CHANGELOG, whether the jump is a sweeping break or a narrow rename. Semver would tell them: 1.0.0 → 2.0.0 shouts "major break", 1.0.0 → 1.1.0 whispers "additive only". The objection is real but misplaced. Schema bumps are, by construction, breaking events — non-breaking changes don't get a date bump, they travel under the existing date. There is no "minor schema bump" category to disambiguate. Every bump is a break; every break demands a codemod and a release note; the CHANGELOG carries the severity information in prose, which is where prose belongs when the information is more than a three-integer summary can hold. Semver tried to compress severity into three integers; for wire formats, the compression loses too much.

The choice also has a virtuous secondary effect on team culture: because every bump is treated as a first-class migration event, there is no incentive to sneak changes in under a "minor" banner. The team either earns the bump (accumulates enough breaking reasons to justify a coordinated migration) or defers it (rolls the proposed breaks into a future bundle). Semver-style nibbling, where a project lands six minor-with-breaking-changes bumps in six months and consumers wake up to discover their code has been silently incompatible for half the period, is not available under a date-stamp regime. The bump is loud by design; that loudness is the feature.

$schemaVersion evolution flow

The lifecycle of a schema version is small, but the decision at its heart — breaking or non-breaking — has a lot of nuance. The flow is linear; the boundary-setting is the work.

Diagram

The decision at BOUND is the load-bearing one. Everything else is bookkeeping. What counts as breaking at the wire-format level?

A working definition: a change is breaking if, after the change, a consumer that parses serialized artifacts under the old grammar can no longer correctly interpret an artifact produced under the new grammar, or if an artifact produced under the old grammar no longer validates against the new grammar. The symmetry matters. Both directions need to be checked. Adding an optional field is forward-compatible (old consumers ignore the new field) and backwards-compatible (old artifacts lack the new field, which is fine because it's optional); this is non-breaking. Removing a field is backwards-incompatible (old artifacts contain a field the new grammar does not know about — probably fine, many parsers ignore unknown fields) but can be forward-incompatible if the field was being relied on (new artifacts lack the field, old consumers crash when they try to read it). Renaming a field is breaking in both directions unless you ship a codemod and, transitionally, accept both names.

Some concrete cases the package has to handle. Adding an optional clearedFor: string[] field to Requirement: non-breaking. Adding a required clearedFor: string[] field: breaking — old artifacts lack the field and fail new validation. Renaming sourceKinds.standard to sourceKinds.industryStandard: breaking — old artifacts contain "type": "standard" which no longer matches any enumerated value. Narrowing source.slot.type from 'string' | 'iso-date' | 'number' | 'url' to 'iso-date' | 'url': breaking if any existing slot was 'string' or 'number'. Widening the same union by adding 'email' to it: non-breaking — no existing artifact could have been 'email' under the old grammar, so no existing artifact is invalidated. Adding a new value to a closed enum (like requirementKinds): non-breaking at the wire level (an old consumer reading a new artifact with kind: 'OperationalConstraint' would simply fail its enum check, but the wire-format bump isn't about that — it's about whether old artifacts remain readable). Removing a value from that same enum: breaking, because old artifacts with the removed kind become invalid.

A heuristic that catches most cases: every closed set (enum, discriminated union discriminator, required field list, slot name list) is a potential breaking-change site. Every open-ended field (optional scalar, free-form string, unbounded array) is a potential non-breaking site. Wire-format bumps happen when closed sets have to shrink or reshuffle; they don't happen when closed sets grow or when optional fields appear.

One subtlety the diagram doesn't show: the codemod branch is not the only thing that ships. The new date also registers a JSON Schema file under schemas/, updates the SCHEMA_VERSION constant in cli/versioning.ts, adds a compatibility block to the library's NON_STRUCTURAL_FIELDS list if the reshape touches non-structural fields, and — crucially — gets its own CHANGELOG entry describing the break in prose, not just in code. The release note is what a consumer reads before running requirements migrate; it is what tells them whether the codemod will handle their case automatically or whether they should plan a manual review. Ship the codemod without the release note and consumers will run it blind. Ship the release note without the codemod and you've failed to discharge your migration obligation. Both, always.

Another subtlety: the decision is symmetric across two directions, and the flowchart's single BOUND box hides a deliberation that may go both ways. Consider a proposed addition of an optional superseding: RequirementId field. Additive at the wire level (old consumers reading the new field ignore it, old artifacts lacking the field default it to undefined) — so the BOUND branch chooses "no bump". But the same change could be reframed as reserving a future discriminator: if the team intends the field to become required at some point, announcing it as optional now and required later is a two-step maneuver that is easier to discuss when both steps are date-stamped. The practical move, in that case, is to bump the date anyway — not because the change is breaking today, but because it signals the onset of a transition window, gives consumers a named moment to pick up the new optional field, and puts the future-required promotion on a clock. The flowchart's BOUND does not enforce this; it is the designer's call. The point is that "technically non-breaking but strategically signal-worthy" is a category, and the team may choose to use a date bump as a coordination device even when the wire-format rules do not demand one.

On the enforcement side: once the new date is registered, compliance --strict in the package's own gate starts emitting artifacts with the new stamp by default. Downstream consumers who have not yet run requirements migrate will see the new stamp flowing into their artifact tree and their --expect-schema-version check (covered later in this chapter) will fail. This is deliberate — the gate is what forces the migration to happen, not a hopeful README line. A consumer who skips the codemod but ships the new library version will discover the mismatch immediately, at build time, with a clear error pointing at the migration command. The ecosystem self-corrects on a commit-by-commit basis, not on a hope-by-hope basis.

Style evolution — the vocabulary diff

Most schema evolution in practice is not a wire-format bump; it is a Style vocabulary change. The team adds a new kind because a recurring reviewer comment ("this is really an operational constraint, not a functional requirement") crystallises into a distinction worth naming. The team adds an intermediate workflow state because the existing Draft → Approved transition is too coarse and a review gate needs its own name. The team adds a new rationale kind because a regulatory audit made them realise they had been conflating "precedent" and "regulatory-compliance". These changes are additive, local, and — if the Style is composed carefully — invisible at the wire-format level.

Diagram

Read the diff. Two changes: a new kind OperationalConstraint appended to requirementKinds, a new state InReview inserted between Draft and Approved with two new transitions (Draft → InReview, InReview → Approved) replacing the single Draft → Approved. Everything else is identical. The risk taxonomy, the source kinds, the statement patterns — all unchanged.

The first change — adding OperationalConstraint — is unambiguously additive. No existing Requirement has kind: 'OperationalConstraint', because the literal wasn't in the v1 vocabulary. The v2 Style accepts every v1 Requirement's kind value, because the v2 set is a superset. Conversely, every v2 Requirement whose kind is one of the original five values still validates under v1. The only v2 Requirements that wouldn't validate under v1 are those whose kind = 'OperationalConstraint', which are new writes under the v2 Style and don't exist yet in any v1-era artifact. This is the model case of a non-breaking Style change. It does not bump $schemaVersion. It does not require a codemod. It propagates at the Style-composition layer — any project that imports DefaultStyle gets the new vocabulary on its next npm install — and shows up in the type system as one new literal in KindsOf<typeof DefaultStyle>. Author's editors offer it in autocomplete. Nothing else changes.

The second change — adding InReview — is more delicate. On the face of it, it looks additive: a new state, two new transitions, the terminal set unchanged. But the edge Draft → Approved that existed in v1 is gone in v2. An existing v1 Requirement that had recorded, in its history, a transition {from: 'Draft', to: 'Approved'} now has an orphan transition — the transition is no longer in the Style's edge set. Is this breaking?

It depends on what you mean by "breaking", and this is the kind of question the next section's table is meant to tame. At the wire-format level, the transition is still representable — the history entry still serializes correctly, the from and to fields are still strings, the JSON is still valid against the schema. At the Style-semantics level, it is breaking, because the Style's StyleValidators.validateSpec (or whatever gate checks history against the declared workflow) would now reject the old transition. At the compile-time level, the StatusesOf<S> union has grown (because InReview was added), which is non-breaking — every old literal is still in the new union. The break is not in the type system; it is in the runtime validator's interpretation of history.

The right conclusion is that InReview-style changes — new intermediate states that replace existing edges — are on the boundary. They don't require a wire-format bump (the serialized shape is unchanged), but they do require a migration consideration: either the Style's validator has to remain lenient about historical transitions that were valid under the old edge set, or the project has to accept that its old Requirements carry audit-trail entries that no longer match the current workflow. Most projects choose leniency — history is history, and annotating it retroactively is its own can of worms. The diagram shows the v1 → v2 lineage as <|-- (inheritance) precisely because v2 accepts v1; the reverse is not true, but the direction that matters for backwards compatibility is v1-artifacts-under-v2, which works.

The classDiagram arrow in the mermaid block is, strictly, misleading: Styles don't inherit in the OO sense — they are const data, and a v2 Style is a new value, not a subclass of the v1 value. The inheritance arrow is used here as a shorthand for "v2 is a conservative extension of v1". In the actual codebase, DefaultStyle is a single const that gets edited, not a class that gets subclassed; the package doesn't ship two side-by-side Style versions in the repo. What it could ship, if the team wanted long-tailed compatibility, is a DefaultStyle_legacy const frozen at v1's vocabulary, kept around for projects that haven't migrated yet. The five shipped presets under packages/requirements/src/styles/default.ts, industrial.ts, lean.ts, agile.ts, kanban.ts — are exactly this kind of data: const-typed, version-stamped, exportable. Freezing a preset at a past vocabulary is a one-line git operation.

Notice what the Style evolution case does not require. It does not require a wire-format bump (the serialized shape is unchanged). It does not require a codemod for additive vocabulary changes (existing specs under the old vocabulary validate against the new vocabulary unchanged, because the new vocabulary is a superset). It does not require rehashing any contentHash (the kind field is a structural content field, but its value on existing specs is unchanged; only newly-authored specs use the new vocabulary). It does not require regenerating requirements-bindings.json (the bindings serialize the current state of the registry, and that state already reflects the new vocabulary on next compliance run). It does not require updating JSON Schema files under schemas/ unless the Style's vocabulary is directly referenced as an enum in the JSON Schema — which, for the DefaultStyle case, it is, meaning the requirement-spec.2026-04-14.schema.json file would get its kind enum updated. But even that update is additive: the JSON Schema's enum array grows, it does not shrink, and any artifact that validated before still validates.

The cost of a Style evolution change is, therefore, near-zero: one line added to a const array, one line of release note, one CHANGELOG bullet. The cost of a wire-format bump is an order of magnitude higher: codemod implementation, schema registration, ripple regeneration, consumer migration coordination. Keeping the two distinct is therefore not just an ergonomic preference; it is a direct cost-curve optimisation. Every change the team can absorb on the cheap Style axis is a change they do not have to pay for on the expensive wire axis. The guiding principle is: push as much evolution into the Style as possible, reserve the wire-format axis for structural truths.

Breaking vs non-breaking Style changes

The following table is the operational summary. Each row maps a kind of Style edit to whether it forces a wire-format bump. The prose after the table walks through each row with a concrete example.

Change Breaking? Reasoning
Add new requirementKind No Existing specs still valid — they just don't use the new kind.
Add new status at a non-reshuffling position (terminal, or between two existing states without changing incoming transitions) No Existing transitions still fire the same way.
Add new status that reshuffles transitions (e.g., new intermediate state REPLACES an existing edge) Yes Existing Requirements pinned to the old transition graph now have orphan transitions.
Remove an existing requirementKind Yes Existing specs' kind value becomes invalid.
Narrow a source.slot type (string → enum) Yes Existing data may not satisfy the enum.
Widen a source.slot type (enum → string) No Existing data still valid.
Add a required field to Requirement Yes Existing specs lack the field.
Add an optional field to Requirement No Existing specs omit harmlessly.

Add new requirementKind — No. The concrete case: DefaultStyle.vocabulary.requirementKinds grows from five entries to six with the addition of 'OperationalConstraint'. Every Requirement that existed before the change declared kind as one of 'Functional', 'NonFunctional', 'Constraint', 'Compliance', or 'UserStory'. All of those literals are in the new union. The new literal is available for new Requirements. No existing artifact becomes invalid; no consumer needs to update its parser; no wire-format bump is required. This is the easy case, and it is the most common case in practice — vocabulary grows as the team discovers they've been conflating categories. The KindsOf<typeof DefaultStyle> type in packages/requirements/src/style.ts grows by one string literal, and the only observable effect is that authors now see 'OperationalConstraint' as an autocomplete option when typing kind = '...'.

Add new status at a non-reshuffling position — No. The concrete case: the statusWorkflow.states array grows by one, the new state is either terminal (appended after 'Deprecated' as an additional terminal like 'Archived') or intermediate between states that don't already have an edge between them. The transitions array grows only by edges involving the new state. No pre-existing edge disappears. Every old Requirement's history is still a sequence of valid transitions. New Requirements can use the new state. The workflow FSM at packages/requirements/src/cli/scenario/project-lifecycle-fsm.ts — the style-aware lifecycle FSM that drives requirement new wizards and compliance status checks — gains a new node, but the existing nodes and edges are intact. Non-breaking.

Add new status that reshuffles transitions — Yes. The concrete case examined in the vocabulary-diff section: inserting 'InReview' between 'Draft' and 'Approved', replacing the Draft → Approved edge with the pair Draft → InReview and InReview → Approved. Old Requirements that recorded, in their history append-only log, the direct Draft → Approved transition now have a history entry whose {from, to} pair is not an edge in the current workflow. Whether you treat this as breaking depends on how strictly the Style validator enforces historical coherence — but the safe design is to treat it as breaking, bump the wire-format version, and either (a) ship a codemod that rewrites historical Draft → Approved entries into a two-step Draft → InReview → Approved sequence with synthetic timestamps, or (b) ship a release note declaring that pre-bump history is interpreted under the old workflow. Either way, the discipline of the version bump forces the decision to be explicit.

Remove an existing requirementKind — Yes. The concrete case: DefaultStyle.vocabulary.requirementKinds shrinks from ['Functional', 'NonFunctional', 'Constraint', 'Compliance', 'UserStory'] to ['Functional', 'NonFunctional', 'Constraint', 'Compliance']. The team has decided that 'UserStory' is redundant — every user story is a 'Functional' Requirement stated in an AgileStyle-specific pattern, and the separate kind doesn't carry information. But every existing Requirement with kind: 'UserStory' now declares a value that is not in the new enum. The StyleValidators.validateSpec check fails. The JSON Schema validation fails. KindsOf<typeof DefaultStyle> no longer contains 'UserStory', so every TypeScript file that authored a UserStory Requirement fails to compile. The change is breaking in every direction: wire, compile-time, and semantic. It requires a $schemaVersion bump, a codemod that reclassifies every UserStory Requirement to a different kind (or rejects it as a manual-review item), and a release note. This is the textbook case for the migration flow.

Narrow a source.slot type (string → enum) — Yes. The concrete case: DefaultStyle.vocabulary.sourceKinds contains a 'regulation' entry whose slots include {name: 'jurisdiction', type: 'string', required: true, hint: 'EU, FR, US, …'}. The team decides the hint has been ignored for years and half the entries spell the jurisdiction in inconsistent ways ('EU', 'eu', 'European Union', 'European_Union'), and the right fix is to narrow the slot to an enum: type: 'enum', values: ['EU', 'FR', 'US', 'JP', 'UK']. Every existing Requirement whose jurisdiction slot holds 'european union' or 'France' now fails validation. The narrowing is breaking. A codemod can handle the common cases — normalise casing, map known aliases to canonical values — but the tail of unexpected strings becomes a manual-review pile. Bump the version; ship the codemod; write the release note.

Widen a source.slot type (enum → string) — No. The inverse of the previous case. The team decides the 'jurisdiction' slot's enum was too restrictive and widens it back to a free-form string. Every old value that matched the enum is trivially a valid string. No existing artifact is invalidated. New artifacts may contain arbitrary strings, but the wire format accepts them — a string is a string. Widening is non-breaking at the wire level. At the Style-semantics level it may be a regression (the project loses its own data-quality discipline), but that is a design question, not a migration question. No $schemaVersion bump.

Add a required field to Requirement — Yes. The concrete case: the team decides every Requirement must declare a clearedFor: string[] field listing the release milestones it is approved for. The motivation is real — a Requirement that was approved for v1 should not silently leak into v2's gate without re-approval. But every existing Requirement lacks the field. Under the new grammar, they fail validation on load. The change is breaking; the codemod has to inject clearedFor: [] (or a project-specific default) on every existing Requirement, which is a structural edit that does change the contentHash of every affected spec, which in turn is exactly the kind of structural-wide bump that makes the pre-existing pin table stale. The migration has to discharge two things simultaneously: the schema bump (add the field) and the pin reconciliation (every Feature's pin table points at hashes that no longer exist; the requirements migrate tool has to either recompute the pins against the new hashes or mark them as manual-review for human approval).

Add an optional field to Requirement — No. The concrete case: the team decides Requirements may carry an optional deprecatedBy: RequirementId field that points at the successor Requirement when the current one is superseded. Optional means omittable. Every existing Requirement omits the field, which under the new grammar means undefined, which is the permissible default. No validation fails. The field appears on new writes when relevant; pre-existing artifacts are unaffected. The contentHash is not affected either, because canonicalize in packages/requirements/src/cli/versioning.ts sorts keys and strips NON_STRUCTURAL_FIELDS — an absent optional field is canonically equivalent to itself under any Style version. No $schemaVersion bump.

The eight rows form a cheat-sheet the team can consult during design review. Any proposed Style edit gets mapped to a row; rows in the "No" column are self-service merges; rows in the "Yes" column are release events that require a codemod and a release note. The boundary is neither arbitrary nor negotiable: it follows from the wire-format compatibility semantics, which in turn follow from the canonicalize contract and the JSON Schema validation pipeline.

The migration script contract

Once a breaking change is merged, the package ships a codemod. The contract it honours is strict: the tool must be deterministic, idempotent (re-running produces no new changes on an already-migrated tree), transparent (diffs are git-diffable), and reportable (every file's outcome is logged to a machine-readable report).

Diagram

Walk the sequence. The user invokes requirements migrate --from 2026-04-14 --to 2026-07-01. The CLI loads every .ts Requirement and Feature file through a ts-morph Project built from the consumer's tsconfig.json. It consults the codemod registry — a package-internal table that maps (fromDate, toDate) pairs to an ordered sequence of codemods — and retrieves the codemods that apply to this migration path. Each codemod is a pure function: AST in, AST out, with a declared touched predicate that lets the CLI skip files the codemod doesn't care about.

For each source file, the CLI iterates the codemods in order. A codemod may transform the AST (returning a changed node), leave it untouched (returning the same node), or flag it as ambiguous (returning a special ManualReview sentinel). Transformed files are written back atomically via ts-morph's saveSync, which emits the source text from the AST and honours the project's prettier config if one is present. Unchanged files are logged. Ambiguous files are left in place with an entry in the report's manualReview array — the user has to open them and decide.

After the file-level pass, the CLI regenerates requirements-bindings.json and related artifacts with the new $schemaVersion stamp. This is the "ripple" step the next section details. Then it emits the report: a JSON payload {migrated: N, unchanged: M, manualReview: P, bindingsRewritten: true} plus a human-readable stdout summary. Exit code 0 if manualReview === 0, otherwise 1 — the tool refuses to claim success while human judgement is still required.

Idempotency is the subtle property. Running requirements migrate --from 2026-04-14 --to 2026-07-01 twice in a row should produce zero additional changes on the second run. Each codemod must be written to be a fixed point on its target: applying it to an already-transformed AST must yield the same AST. Practically, this means codemods are conditional rewrites, not blind search-and-replace. A "rename sourceKinds.standard to sourceKinds.industryStandard" codemod checks whether the target file still contains the old discriminator before rewriting; if all usages already read industryStandard, the codemod is a no-op. The idempotency property is tested by a property-based check in the migration tool's own test suite: generate a random tree, run the codemod, run it again, assert the second run produces no diff.

Git-diffability matters because the migration is, ultimately, a commit. The team reviews the migrate's output in a pull request; they read the diff line by line (or rather, inspect each file's hunks); they merge or reject. A codemod that re-orders unrelated lines, rewrites whitespace, or strips comments corrupts the diff and makes review impossible. The tool uses ts-morph's AST-preserving emitter specifically to avoid these drift effects: the only lines that change are the lines the codemod touched.

The requirements migrate command is the mechanical counterpart of the release note. The release note says here is what changed. The codemod says here is how to change your repo to match. Together they discharge the migration burden. Separately, either is incomplete.

A few additional properties of the migration contract earn explicit mention. First, the tool writes a migration log — a timestamped JSON file under requirements/migrations/<fromDate>-to-<toDate>.json — which records the exact set of files touched, the codemods applied in order, and any manualReview flags. The log is committed alongside the migration diff, and future audits can reconstruct what the tool did and why. Second, the tool supports a --only <glob> flag for incremental migrations: a large codebase may choose to migrate its Requirements in one commit, its Features in a second, its bindings regeneration in a third. The flag constrains the file scope; the --expect-schema-version gate, correspondingly, has a per-subtree mode that tolerates mixed-version states during such a staged migration. Third, the tool supports a --reverse flag for rollbacks — each codemod, if properly authored, carries an inverse codemod, and the migration registry can be walked backwards. Rollbacks are rare and discouraged (the migration log plus git's own revert is usually the right tool) but the capability is there for emergency cases where a breaking change must be undone in a hot-fix.

The idempotency contract deserves one more remark. It is not merely a convenience ("the developer can re-run without harm") — it is the foundation on which the --dry-run workflow rests. A --dry-run invocation is, under the hood, a full migration run that would write but instead reports its would-writes to stdout. Because the real run is idempotent, the dry run's report is a faithful preview: whatever the dry run says would change is exactly what a real run will change, assuming no concurrent edits to the tree. Without idempotency, the dry run would have to simulate side-effects without applying them, which in turn requires a simulation layer that drifts out of sync with the real codemod logic. Idempotency collapses the simulation into a single code path, which is the single most maintainable property a codemod framework can have.

Composing codemods

A single schema bump may bundle several breaking changes. The migration tool's sequencing contract has to handle the bundle coherently. Consider a concrete scenario that the package may face between 2026-04-14 and 2026-07-01:

  1. Remove the 'UserStory' kind from DefaultStyle.vocabulary.requirementKinds.
  2. Add a required clearedFor: string[] field on every Requirement.
  3. Rename sourceKinds.standard to sourceKinds.industryStandard.

Three independent changes, three codemods, one migration path. The order matters, and the right order is not the order the bullets are written in.

The wrong intuition would be to apply them in the order they are listed. But renaming sourceKinds.standard first would touch every Requirement that uses a standard-kind source, which triggers a structural edit on those files. Structural edits change the contentHash (even if cosmetic-change-immune fields like $schemaVersion are stripped, the source.type value is semantic content). Every pre-existing pin table that pointed at those Requirements' old hashes is now stale. If a later codemod in the sequence produced additional structural edits, the pin tables would be stale again, and the user-visible diff would conflate multiple causes into a single revision. Worse, if the rename codemod runs before the field-add, the field-add has to touch every file the rename already touched, doubling the write churn.

The correct order is: field-add first (with a default), rename second (an idempotent renaming), remove-kind last (which fails loudly on any remaining UserStory references). Let me walk through why.

The field-add codemod is unconditional: every Requirement needs a clearedFor: string[] field, which will be injected with a default of [] (or a project-specific seed). Running this codemod first establishes the structural invariant — every file now has the field — before any other codemod looks at the tree. It is also the codemod whose effect is most sweeping, touching every file; front-loading it keeps the tree in a consistent shape for the later codemods to reason about.

The rename codemod is conditional on content: only files that reference sourceKinds.standard get rewritten. It is idempotent (running it on a tree where every usage already reads industryStandard does nothing). It is narrow (touches only source.type field values). Placing it second means it runs against a tree that already has the new field, which doesn't affect its logic — renames don't interact with field additions — but it also means its diff in the commit is clean: these N files changed standard to industryStandard, no confusion with the field-add's sweep.

The remove-kind codemod is the filter. It checks every Requirement whose kind = 'UserStory' and either reclassifies it (if the team has a default — say, 'Functional' — for the reclassification) or flags it as manualReview. Running it last means the preceding codemods have already done their work on these files; the remove-kind codemod only needs to decide the kind question. If it flags a file, the user opens one file and makes one decision; they don't have to re-reason about field presence or source type.

The ordering rule generalises: codemods whose effect is introducing a structural invariant run before codemods whose effect is content transformation, which run before codemods whose effect is filtering or rejection. Think of it as a small compiler pipeline: structural pass, rewrite pass, validation pass. Each pass sees the output of the previous one in a known, consistent shape.

The ordering is testable. The migration tool's test suite includes a property-based harness — fast-check generators produce random sequences of (codemod, tree) pairs, apply them in various orders, and assert that the canonical order (structural, rewrite, filter) produces the same final tree as any legal permutation within those buckets and that the canonical order produces strictly fewer intermediate writes. Re-ordering across buckets produces either a different final tree (a bug) or the same tree with more writes (inefficient). The property-based harness catches both.

A closer look at the ordering rule shows why buckets, not individual codemods, are the right granularity. Codemods within a bucket commute: adding a required field to every Requirement and adding a different required field to every Feature can happen in either order with the same final tree. Codemods across buckets do not commute: applying a filter codemod before a rewrite codemod risks rejecting files that the rewrite would have saved. The bucket abstraction formalises the commutation structure: within a bucket, any permutation is legal; across buckets, the canonical order (structural → rewrite → filter) is mandatory. The property-based test suite exercises both halves of this rule by generating sequences that respect bucket order and asserting final-tree equivalence, then generating sequences that violate bucket order and asserting that the violation produces either a diff or a manual-review entry that the canonical order would not have produced.

There is a practical reason to keep the bucket count small. Three buckets cover every case the package has encountered in practice; adding a fourth bucket would add combinatorial complexity to the ordering proofs without buying more expressive power. The three-bucket design is a deliberate simplification, not a fundamental law — a future migration might discover a case that needs a fourth bucket (say, "audit-log injection", where every touched file gets a synthetic history entry recording the migration), but the current package ships with three, and the discipline of "fit your codemod into one of the existing buckets, or open a design discussion" is a useful forcing function against codemod proliferation.

The codemod registry is itself a versioned artifact. Between 2026-04-14 and 2026-07-01, the registry entry reads something like:

registerCodemodPath({
  from: '2026-04-14',
  to: '2026-07-01',
  codemods: [
    { kind: 'structural', id: 'add-required-cleared-for', apply: addClearedForField, idempotent: true },
    { kind: 'rewrite',    id: 'rename-standard-to-industry-standard', apply: renameStandardSource, idempotent: true },
    { kind: 'filter',     id: 'remove-user-story-kind', apply: reclassifyUserStories, idempotent: true },
  ],
});

The kind field encodes the bucket; the registry's own invariant check refuses to accept a codemod whose idempotent flag is false (the tool only honours deterministic migrations). The apply functions are pure: AST in, AST out, no I/O. The CLI shell injects the file system, the ts-morph project, and the report collector around the pure core — consistent with the port-driven architecture the rest of the package follows.

The $schemaVersion bump ripple

A wire-format bump is not an isolated change; it propagates through every artifact that carries a version stamp. Understanding the fan-out is how you know the migration is complete.

Diagram

Each node in this fan-out corresponds to a concrete write or rewrite the migration tool has to perform. Missing one of them produces a split-brain state where some artifacts are under the new schema and others under the old. Enumerate carefully.

requirements-bindings.json. This is the serialized dump of the compliance scanner's registry — the flat file that downstream consumers (the TUI explorer, the site generator, the CI dashboards) read. It carries a top-level $schemaVersion field. On migration, the scanner re-runs against the migrated source tree and writes a fresh file with the new stamp. The stamp is the only thing a consumer reads to decide which parser version to load.

compliance-report.json. The gate's output payload, schema-registered under schemas/compliance-report.schema.json. Same treatment: re-run the gate, emit a new payload with the new stamp. If the schema bump adds fields to the compliance report (say, drift: VersionDrift[] from the previous chapter), the new payload includes them; the old schema file remains registered under its old date for archival readers.

trace-matrix.md. The Markdown table of Requirements × Features. It doesn't carry a $schemaVersion stamp in the Markdown itself — Markdown has no natural stamp location — but it is regenerated from the fresh bindings, so its content reflects the new grammar (e.g., any renamed sourceKinds now show the new discriminator in the provenance columns).

Every .ts Requirement/Feature that declared $schemaVersion inline. Most Requirements don't declare the stamp inline — the default behaviour is to emit it only on serialized artifacts. But some projects, for traceability reasons, annotate their source with $schemaVersion: '2026-04-14' as a field on the spec object. Those inline declarations are rewritten to the new date. The canonicalize function in versioning.ts strips the field before hashing (it is in NON_STRUCTURAL_FIELDS), so the rewrite does not change contentHash, and the existing pin tables remain valid. This is why the stamp is in NON_STRUCTURAL_FIELDS in the first place: so that schema bumps do not spuriously re-version every Requirement.

schemas/*.schema.json. A new JSON Schema file per spec type, named with the new date. The old ones remain in the directory — the registry is append-only. Archival readers decoding a 2026-04-14 artifact load the 2026-04-14 schema; readers decoding a 2026-07-01 artifact load the newer one. The registry's lookup is by date literal. There is no "latest" pointer; consumers should always dereference through the stamp they read from the artifact.

Pin tables. The VersionPin entries recorded on Features or in external pin tables freeze (targetId, pinnedVersion, pinnedHash) tuples. If the schema bump's codemods produced structural edits — which is the common case, since breaking changes typically involve structural transformations — the contentHash of affected Requirements changes, and every pin pointing at an old hash is now stale. The migration tool either rehashes the pins automatically (if the structural edit is deterministic and the team accepts auto-rehashing as a policy) or flags them as drift to be resolved manually through the requirements compliance --strict gate from the previous chapter. The choice is a policy decision: auto-rehash is convenient but erases the audit boundary; manual review is rigorous but slow.

CHANGELOG.md entry. The human-facing narrative of the bump. What changed, why, how to migrate, what edge cases the codemod does not handle. This is what a consumer reads before running requirements migrate; it is also what a future archaeologist reads to understand the motion of the grammar through time. CHANGELOG entries for schema bumps are longer and more structured than entries for ordinary feature additions — they cite the codemods shipped, the manual-review categories, the post-migration invariants.

SCHEMA_VERSION constant in versioning.ts. The package's own source-of-truth for the current wire-format version. export const SCHEMA_VERSION = '2026-04-14' becomes export const SCHEMA_VERSION = '2026-07-01' in the commit that lands the bump. This is the constant that the scanner reads when emitting fresh artifacts and that validateSpec reads when checking whether an incoming artifact's stamp matches the current grammar.

Ten nodes, one bump. A consumer running the migration reads the ripple diagram to decide whether every branch has been handled. A reviewer of the migration PR checks that the diff touches all ten nodes (or explicitly declares that the bump does not affect one of them — e.g., if the codemod is pure renames, the pin tables may be unaffected, but the argument must be made). Anything less is a partial migration, and partial migrations compound.

The "compound" part is worth dwelling on. A partial migration leaves the repository in a mixed-version state: some artifacts stamped with the old date, some with the new. The next time somebody runs compliance --strict, the scanner reads the mix and either (a) picks a majority version and warns about the minority, (b) fails with an ambiguity error, or (c) silently emits fresh artifacts under whichever version the SCHEMA_VERSION constant now declares, effectively propagating the new stamp without handling the old data. All three are bad outcomes. The --expect-schema-version gate is specifically designed to catch the mixed state: if any loaded spec declares a version different from the expected one, the gate fails. A partial migration therefore manifests as a gate failure at the next test:all invocation, which is the earliest possible signal. It does not silently slip into production. But the failure has to be triaged, and the triage involves finding the partial-migration PR, auditing what was skipped, and running the remainder of the codemods — which is enough friction that the lesson is usually learned once per team.

The ripple diagram is also a checklist for tooling authors. A team that builds a new downstream consumer of requirements-bindings.json — say, a dashboard that visualises the coverage matrix as a heat-map — inherits the obligation to add its artifact to the ripple. If the dashboard caches the bindings, the cache has to be keyed by $schemaVersion or invalidated on bump. If the dashboard uses the JSON Schema to drive its UI (showing only fields the schema declares), it has to load the correct version of the schema. If the dashboard produces its own artifacts (a snapshot of the heat-map, a CSV export), those artifacts should carry the same stamp as the bindings they summarise. Forgetting any of these produces, again, a partial migration — not in the core package's artifacts but in a downstream consumer's. The discipline the core package imposes on itself has to extend to its consumers or the ecosystem drifts.

Style composition across a schema bump

If the previous chapters' modularity advice has been followed — if the project composed its Style off DefaultStyle via a composeStyle() primitive rather than hand-copying the vocabulary — a schema bump that changes DefaultStyle's vocabulary propagates automatically on npm install. If the advice was not followed, the bump is missed silently.

The concrete case: a project called SsgStyle extends DefaultStyle with an additional requirementKind value 'BuildStep' and an additional sourceKind 'build-pipeline'. There are two ways to declare this.

The correct way:

import { DefaultStyle } from '@frenchexdev/requirements/styles/default';
import { composeStyle } from '@frenchexdev/requirements/style';

export const SsgStyle = composeStyle(DefaultStyle, {
  vocabulary: {
    requirementKinds: [...DefaultStyle.vocabulary.requirementKinds, 'BuildStep'] as const,
    sourceKinds: [
      ...DefaultStyle.vocabulary.sourceKinds,
      { kind: 'build-pipeline', label: 'Build pipeline', slots: [
        { name: 'stage', type: 'string', required: true },
        { name: 'tool',  type: 'string', required: true },
      ]},
    ] as const,
  },
} as const);

The incorrect way (hand-copying the vocabulary):

export const SsgStyle = {
  id: 'ssg',
  version: '1.0.0',
  vocabulary: {
    requirementKinds: ['Functional', 'NonFunctional', 'Constraint', 'Compliance', 'UserStory', 'BuildStep'],
    // ... the rest of DefaultStyle's vocabulary copied verbatim ...
    sourceKinds: [
      { kind: 'stakeholder', /* ... copied ... */ },
      { kind: 'regulation', /* ... copied ... */ },
      // ... etc ...
      { kind: 'build-pipeline', label: 'Build pipeline', slots: [/* ... */] },
    ],
  },
  // ...
} as const;

The first form pulls its base from DefaultStyle by value — literally spreads DefaultStyle.vocabulary.requirementKinds into the new array. If DefaultStyle grows its vocabulary in a future version (adds 'OperationalConstraint' from the earlier example), the spread picks up the addition automatically. SsgStyle's vocabulary on the next npm install contains the six original values plus 'BuildStep' plus 'OperationalConstraint', without a single line of code changed in the consuming project. This is the OCP-compliant form — open for extension (the project added 'BuildStep'), closed against upstream modification (the consuming project does not need to be re-edited when DefaultStyle grows).

The second form freezes the upstream vocabulary at the moment of authoring. If DefaultStyle grows 'OperationalConstraint', SsgStyle doesn't see it. Authors writing Requirements under SsgStyle can't use the new kind; KindsOf<typeof SsgStyle> remains stuck at the old seven values. The upstream grammar has moved, the downstream grammar hasn't, and the divergence is silent — no error, no warning, just a slow drift between what DefaultStyle says is the current vocabulary and what SsgStyle actually exposes. A year later, somebody notices that SsgStyle doesn't offer 'OperationalConstraint' in its autocomplete; the bug is traced to the hand-copy; the fix is to re-copy; the same bug recurs six months later when DefaultStyle grows another kind. This is exactly the pattern feedback_dsl_open_closed_via_generators names as the anti-pattern: a DSL should be parameterised by its ontology, not blocked on a pre-built library.

composeStyle() is the primitive that makes the first form ergonomic. Its signature (simplified):

export function composeStyle<
  Base extends RequirementStyle,
  Extension extends DeepPartial<RequirementStyle>,
>(base: Base, extension: Extension): Merged<Base, Extension>;

It deep-merges extension into base, preserving as const narrowing so that the returned type's KindsOf, StatusesOf, etc. remain literal unions rather than widening to string[]. The implementation is pure data manipulation; the interesting engineering is in the type-level merge that preserves the const narrowing. The result is that a project wiring up its Style through composeStyle() rides the grammar bus — every additive upstream change flows through npm install without a single line of the consuming project needing to change.

The operational argument for composeStyle() is therefore not ergonomic; it is structural. The primitive exists specifically so that schema bumps on the base Style ripple into consuming projects without ceremony. Projects that skip it are opting out of the ripple and, in doing so, opting into manual re-synchronisation every time the base moves. For small projects with one Style, this is a cost they can absorb. For the monorepo — fifteen packages, several with their own Styles — the cost compounds and composeStyle() is the only maintainable option.

Related: the five shipped presets under packages/requirements/src/styles/default.ts, industrial.ts, lean.ts, agile.ts, kanban.ts — are the reference. Each is a single const-typed export. Each is versioned independently (the id and version fields on RequirementStyle carry the preset's own ordinal). Each ships its own vocabulary, validators, templates, and reporter. When a project wants to mix two — say, IndustrialStyle for safety-critical Requirements and DefaultStyle for everything else — the composition happens at the Requirement class level: class SafetyReq extends Requirement<typeof IndustrialStyle>, class UXReq extends Requirement<typeof DefaultStyle>, side by side in the same repo, each narrowed to its own Style. A single project can live under multiple grammars simultaneously, which is both a feature and a vigilance requirement — the schema-bump ripple has to consider every Style in use, not just the one the team thinks of first.

The five presets serve as concrete migration case studies in their own right. When IndustrialStyle ships a new safety integrity level — say, the IEC 61508 update introduces a SIL 4+ category — the change is additive to the riskTaxonomy.levels array and non-breaking at the wire level. When LeanStyle revises its A3 template to include a new slot for "countermeasures", the change is a StyleTemplates edit and affects only new requirement new wizards; existing Requirements authored under the old template are untouched. When AgileStyle introduces an INVEST validator update that narrows the "small" criterion (e.g., a stricter story-point threshold), the change is a StyleValidators edit; existing Requirements that passed the old validator may fail the new one, and the migration path is a --dry-run that flags the newly-failing specs for rework. When KanbanStyle revises its Classes of Service enumeration, the change is a StyleVocabulary edit and behaves like the DefaultStyle requirementKinds cases above — additive non-breaking, removing breaking. Each preset has its own evolution history, and each history is worth its own paragraph in the release notes. The fact that all five are built on the same RequirementStyle interface means the same codemod machinery serves all five; the vocabulary of the codemod registry is rich enough to express edits to any of them.

Semver for the package itself

A last distinction to close the loop. The @frenchexdev/requirements package is a library. It has an npm version — semver, per the npm ecosystem's conventions. That version is unrelated to $schemaVersion.

  • @frenchexdev/requirements@1.2.3 — the npm semver for the library. Bumped when the public API of the package changes, according to semver's conventional rules (patch for bug fixes, minor for additive API changes, major for breaking API changes).
  • $schemaVersion: '2026-04-14' — the wire-format version of the artifacts the library emits. Bumped per the rules in this chapter.

The two can bump independently. A patch fix to the CLI (say, a bug in requirements compliance's output formatting, bumping 1.2.3 → 1.2.4) does not touch the schema; the emitted artifacts' $schemaVersion remains 2026-04-14. A major library redesign (say, renaming the @Satisfies decorator to @Fulfills, bumping 1.x.y → 2.0.0) may or may not coincide with a schema bump — the decorator rename is a breaking API change, not a breaking wire-format change; the emitted JSON is unchanged.

The decoupling is operationally important. Consumers upgrade the library on their own cadence — often automatically, via Dependabot or Renovate, for patch and minor bumps; sometimes manually, for majors. A schema bump is a different kind of event — it touches every downstream artifact, requires a codemod run, produces a reviewable diff. Conflating the two would force every library patch bump into the schema-bump ceremony, which is wasteful, and would force every schema bump through a major library bump, which misleadingly signals that the library's public API has changed when in fact only the wire format has.

The release cadence that emerges in practice: library versions bump roughly monthly (bug fixes, new CLI flags, additive Style helpers); schema versions bump roughly yearly (the grammar ossifies between major reconsiderations; the team earns a schema bump by accumulating enough breaking reasons to justify the migration work). The ratio is rough but representative — most long-lived schema-versioned systems (Stripe, Kubernetes, CloudEvents) show a similar pattern.

The CHANGELOG entries distinguish the two. A library release note reads Added the --replay flag to requirement new. Fixed an off-by-one in the compliance report's AC counter. A schema release note reads Schema 2026-07-01: removed the UserStory kind, added required clearedFor field, renamed sourceKinds.standard. Codemod available via requirements migrate --from 2026-04-14 --to 2026-07-01. Expected manual-review fraction: 3–8% of Requirements. The two notes live in the same CHANGELOG, but they describe different kinds of event.

The decoupling has one more operational consequence worth naming: library majors and schema bumps should usually not coincide. A consumer processing a 1.x.y → 2.0.0 library bump is already absorbing API changes (renamed decorators, reshaped port interfaces, modified default configs); asking them to also migrate their spec tree in the same commit multiplies the cognitive load and increases the chance that something slips through review. The preferred pattern is to ship library majors and schema bumps in separate releases, with a minor release in between that contains neither so the consumer can validate the library upgrade before tackling the schema. A team willing to be patient about releases absorbs the discipline naturally; a team shipping breaking changes weekly will find the discipline impossible and should probably reconsider whether its library is stable enough to be called 1.x at all.

$schema alongside $schemaVersion

Some VersionedSpec shapes carry both fields. $schemaVersion is the date-stamp — the key into the historical grammar registry. $schema is a relative path (or URL) to a JSON Schema file — a convenience pointer that IDEs like VS Code use to drive autocomplete, inline validation, and hover-hint documentation in .json editors.

The two are complementary. $schemaVersion is semantically load-bearing: it is what the runtime parser reads to decide which grammar to use. $schema is semantically redundant (the runtime parser could infer the right schema from the $schemaVersion registry lookup) but ergonomically essential: VS Code doesn't run the package's registry lookup, it follows the $schema pointer. A spec that carries "$schemaVersion": "2026-04-14" without a $schema pointer will validate correctly at runtime but will not autocomplete in the editor.

The JSON-Schema community convention, codified in the draft-2020-12 metaschema, is to use $schema as the first key of a JSON object, as a pointer to the schema file that governs the object. The convention is widely honoured across ecosystems — package.json, tsconfig.json, and countless config formats all use it. The requirements package follows suit: its emitted artifacts carry $schema as the first key, pointing at the schema file matching the artifact's $schemaVersion. The two fields are written together and read together; a migration's ripple diagram includes both (the new $schemaVersion points at the new schema registry entry, and the new $schema pointer references that entry's file path).

There is one subtlety. The $schema path may be absolute (an URL) or relative (a path within the consumer's repo). Absolute URLs are unambiguous but introduce a runtime dependency on the host serving the URL; relative paths are self-contained but only work if the consumer has the schema file in their repo. The package's default is relative — the schema registration step (requirements schema register) copies the appropriate schema files into the consumer's project and points $schema at the local copy. Offline-first, reproducible, no network at read time. Consumers who prefer the absolute form can override the pointer; the validation pipeline accepts both.

The relationship is worth stating once more for emphasis: $schemaVersion is what grammar the artifact was written under; $schema is where to find that grammar's specification. One is a date-stamped identifier; the other is a file-path pointer. They always appear together, they always refer to the same schema version, and they both get updated by the migration tool in lockstep during a wire-format bump.

Running migrate in CI

The migration tool has to integrate with the local-CI discipline that the rest of the repo follows — no cloud CI, no GitHub Actions, no Vercel build-step tests. The gate runs on the developer's machine before git push. The question is how to make requirements migrate a first-class participant in that gate without turning every routine test:all run into a destructive migration invocation.

The design: two new flags.

  • compliance --strict --expect-schema-version 2026-04-14 — fails if any loaded spec declares a $schemaVersion different from the one given. This is the gate check: it asserts that the repo is consistent with a single declared wire-format version, and it is run on every test:all invocation. If the check fails, the gate refuses to accept the commit until either the repo is migrated (bring all specs to the new version) or the expected version is updated (the team has decided to stay on the old version a while longer).

  • migrate --dry-run — runs the migration codemods against the tree, reports what would change, writes nothing, exits 0 regardless of the diff. This is the informational check: it is run in test:all as a non-gating step, printing a report of which files would be touched by the currently-registered codemods. A developer who has pulled from main and sees a pending migration gets an early warning, without the test:all pipeline aborting on their behalf.

Diagram

The sequence covers the common case. compliance --strict --expect-schema-version is the hard gate; migrate --dry-run is the soft informational. Neither runs the migration destructively; the actual migration is a separate, explicit command the developer runs when they decide to accept it.

What happens on a schema bump? The library's SCHEMA_VERSION constant moves to '2026-07-01'. Downstream projects that pull the new library on their next npm install find their test:all failing: the --expect-schema-version check (still configured, in the consumer's requirements.config.json, to expect 2026-04-14) reports that the new bindings — written by the freshly-installed library's compliance scanner — carry '2026-07-01', which does not match the expected value. The gate fails. The developer now has three choices:

  1. Run requirements migrate --from 2026-04-14 --to 2026-07-01 and update requirements.config.json's expected-version to '2026-07-01'. This is the migration path.
  2. Pin the library version in package.json to a range that excludes the new release (^1.4.0 when the bump is at 1.5.0). This is the defer path.
  3. Override --expect-schema-version in requirements.config.json to '2026-07-01' without running the codemod. This is the forceful path; it leaves existing specs with stale stamps, and the next compliance run will complain loudly about the divergence. The path exists for edge-cases (a consumer who has already migrated manually) but is not a normal choice.

The --expect-schema-version flag is, in effect, the pinning primitive for the wire-format. It is to the schema what pinnedVersion / pinnedHash are to an individual Requirement, from the previous chapter — a frozen target, a gate check, a drift signal. Both pinning mechanisms live in the same gate for the same reason: they turn ambient drift into legible, actionable failures.

The local-CI constraint rules out sophistication. There is no distributed build, no matrix of schema versions, no automated cross-compatibility testing. The developer's machine runs the test suite, the gate runs once before git push, and everything has to be fast enough to not get bypassed. The chosen design — two flags, deterministic checks, dry-run informational — meets that constraint. Anything heavier would get disabled within weeks by frustrated developers, and the gate would fail silently.

Concretely, the --expect-schema-version check adds perhaps 15 milliseconds to the overall test:all budget, because it is a single pass over the already-loaded spec list that compares each spec's $schemaVersion string to the expected value. It fits comfortably within the budget a developer will tolerate. The migrate --dry-run informational check is more expensive — it walks every codemod and reports would-changes — but still bounded by AST traversal cost, which for a repository with tens of thousands of lines of TypeScript specs is in the single-digit seconds. Running it as a non-gating informational step in test:all means developers see pending migrations early without the pipeline itself blocking on them. The "non-gating informational" category is a deliberate middle ground between ignored warnings (which everyone learns to filter out) and hard failures (which produce push-back when they are not actionable); it sits on stdout as a bullet in the gate's summary, visible but not obstructive.

The design also assumes trust in the developer's own discipline — a --no-verify flag on git push can bypass the gate, as can any wrapper script that routes around the compliance check. The package does not attempt to enforce the gate with technology the developer controls; that would be a losing battle, and the point of the local-CI philosophy is that developer ownership of the gate is the feature, not the bug. What the design does is make the gate pleasant enough to use that bypassing it feels like cutting a corner rather than avoiding a nuisance. Every design choice — the fast checks, the informational dry-run, the clear error messages pointing directly at requirements migrate — is aimed at the emotional valence of the gate, not just its technical correctness. A gate that feels like an ally gets run. A gate that feels like an adversary gets bypassed. The schema-evolution gate is authored to be an ally.

The DSL as a living standard

Requirements DSLs are an unusual species of software. Most DSLs have a clean boundary: the author writes code in the DSL, a compiler consumes it, a target system executes. The author and the target are separated by a compile step; the DSL's grammar can evolve as long as the compiler evolves with it, and old code either gets recompiled or stays frozen at its old toolchain.

A Requirements DSL is different because its authors and its target systems are intertwined. The Requirements are the specification that Features implement and that tests verify. When a Requirement's grammar changes, the Features written against the old grammar don't silently break — they compile fine, because the grammar change is at the vocabulary level and the Feature class doesn't care what requirementKind its Requirement declares. They break in a more insidious way: the semantic contract between the Requirement and the Feature, the thing the gate is meant to enforce, drifts. A Requirement that was approved under a vocabulary that contained 'UserStory' is now, under the new vocabulary, unclassified; the Feature that was supposed to satisfy it is now satisfying a Requirement in a state the gate cannot interpret; the audit trail that cited the old grammar now cites a grammar that no longer exists.

A schema bump, in that sense, is a regulatory event. It is not just a library upgrade. It is a change to the statutes under which specifications were authored. The codemod is the historical-legal equivalent of a transitional provision in legislation: specifications drafted under the old statute shall be interpreted under the following rules. The release note is the equivalent of an explanatory memorandum. The --expect-schema-version gate is the equivalent of a commencement order: no new specification under the old statute shall be accepted as of this date.

The metaphor is not entirely figurative. The @frenchexdev/requirements package was designed from the outset as a dogfood target for a traceability discipline that the monorepo's other packages — including the legal-rule DSLs and the compliance toolchain — also use. The discipline this chapter describes is exactly the discipline one would want from a formal-specification language that aspires to live longer than any individual team that writes specs under it. Catala, the French legal-rule DSL this series has cited repeatedly, faces the same question: when the underlying statute changes, what happens to the rules written under the old version? Catala's answer — annual releases, explicit version stamps, legal commentaries accompanying each release — is the legal-sector equivalent of what this chapter has described.

The discipline is what lets a team do a schema bump without silently corrupting the audit trail. Every existing artifact is either migrated (through a codemod, with a diffable commit) or explicitly frozen at its old stamp (with a pinned configuration). There are no hidden drifts. There is no one moment when the team realises, months later, that a Requirement they thought was approved under the current grammar is actually floating in a grammar from two schemas ago. The stamps travel with the artifacts; the codemods discharge the migration burden; the gate enforces the consistency; the CHANGELOG documents the history.

The audit-trail property is especially load-bearing in regulated contexts. A medical-device firmware project running IndustrialStyle has its Requirements as part of the submission dossier that justifies the device's safety case. A schema bump mid-project is not an engineering decision — it is a regulatory-affairs decision, because it changes the grammar under which the submission was authored. The discipline of this chapter gives the regulatory-affairs team a coherent story: yes, we migrated at this date, here is the codemod, here is the diff, here is the CHANGELOG explaining why. Every spec in the submission dossier either carries the new stamp (and is documented as migrated) or the old stamp (and is documented as frozen, with a rationale). The auditor can reconstruct the history by reading the stamps and the commit log. Compare this with the alternative, where the grammar changed silently, some specs were rewritten under the new grammar and others weren't, and nobody kept track — an auditor reading that dossier cannot tell what is approved under what, and the submission is compromised. Regulated projects choose the discipline not because they love ceremony but because the alternative is unlivable at submission time.

Even in unregulated contexts — a consumer SaaS, an internal tool, a volunteer open-source project — the discipline pays off whenever the lifespan of the Requirements exceeds the lifespan of the team members who wrote them. A new hire six months in, reading a Requirement stamped 2026-04-14 in a codebase whose current schema is 2027-01-15, can unambiguously interpret the grammar under which the Requirement was authored. Without the stamp, they would have to reconstruct the grammar from context, guess at deprecated discriminators, or ask the original author (who may have moved on). With the stamp, they read the archive entry and know. The investment in stamping is small — a single field on every artifact, a single constant in the library — and the payoff is durable across every team change the project outlives.

What a reader of this chapter should take away is that versioning the language is a different, harder problem than versioning individual artifacts written in the language. The previous chapter's version counters and content hashes handled the easy problem — one spec at a time, under a fixed grammar, with a clear bump rule. This chapter's schema stamps and codemods handle the hard problem — the grammar itself in motion, with every spec simultaneously at stake, and with the discipline of archival reproducibility to honour. The two chapters belong together; either alone is half the story.

A final practical note, for the team considering their first schema bump. The smallest useful bump is not the smallest change — it is the smallest change that forces the team to run the migration pipeline end to end. Shipping an additive requirementKinds entry is too small; it doesn't exercise the codemod registry, the --expect-schema-version gate, the --dry-run informational, or the ripple to bindings and reports. A better first bump is one that genuinely breaks — add a required field, or rename a discriminator — specifically so that the whole machinery runs, the migration pipeline is validated against a real case, and the team has done the exercise before the day they genuinely need it for a large-scale reshape. Practice migrations on a small deliberate break; you will find the bugs in the codemod registry, the gaps in the ripple diagram, the oversights in the CHANGELOG process. The day a large bump is genuinely needed, the machinery will already be rehearsed. This is the cheapest insurance the discipline offers, and it is often the part that gets skipped because it seems ceremonial. It is the opposite of ceremonial. It is the field test.

The spec evolves. The language the spec is written in evolves. These are not the same change, and this chapter has been about the second one — the harder one, the one that touches every artifact in flight, the one that earns the package the right to call itself a living standard rather than a fixed library. The primitives are in packages/requirements/src/cli/versioning.ts and packages/requirements/src/style.ts. The presets are in packages/requirements/src/styles/. The FSM that governs the project-lifecycle side of the story is in packages/requirements/src/cli/scenario/project-lifecycle-fsm.ts. The codemod infrastructure is what the team ships next — informed, one hopes, by the discipline this chapter has described and the twelve chapters that came before it.

⬇ Download