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 22d — Lifecycle Discipline: Versioning Without Semver

A requirement is a normative fact with an audit trail. Versioning it with semver is a category error.

The package @frenchexdev/requirements does not ship a requirements/package.json with a "version": "2.3.1" field on every REQ-* class. It never will. The point of this chapter is to explain why, and to describe — with the real files in hand — the four primitives that take semver's place when the artefact being versioned is a requirement rather than a library.

The four primitives are already on disk. They live in packages/requirements/src/base.ts, packages/requirements/src/decorators.ts, packages/requirements/src/cli/versioning.ts, and packages/requirements/src/styles/default.ts. A reader who has followed the series this far will have seen each of them in isolation. This chapter joins them into a single mechanism: status + history[] + @Refines + VersionInfo/VersionPin, composed under the compliance --strict gate that Chapter 13 describes. Together, they are the lifecycle discipline.

The category error

Semver answers a very specific question: will my code still compile and behave the same way after I upgrade from 2.3.1 to 2.3.2? The question has callers. A library is a function over an API surface, and every consumer of that surface is a caller. A patch bump promises the callers that their call sites still typecheck and still do the same thing at runtime. A minor bump promises additive surface — new calls are available, old calls unchanged. A major bump is a warning: your call sites are going to break; read the migration notes. The three numbers encode a three-tier contract about source-level compatibility.

Now ask the same question of a Requirement. Will my code still compile after REQ-NO-JS is upgraded from 2.3.1 to 2.3.2? The question does not parse. REQ-NO-JS has no callers in the semver sense. Nothing imports REQ-NO-JS and invokes its methods. A Feature class carries @Satisfies(NoJavaScriptRequirement, …), which means the Feature's author is claiming to deliver the rule the Requirement states — not that the Feature calls into the Requirement. The Requirement is not an API. It is a normative fact, a rule the system is supposed to obey, a claim about how the world should be. The only thing that "calls" it is a human audit — a regulator, a CISO, an insurer, a new hire reading the repository six months after you wrote the rule.

What actually matters about a Requirement's evolution is not source compatibility. It is something closer to faithfulness: does the set of Features currently claiming to satisfy this Requirement still, in fact, satisfy it under the current wording of the Requirement? If the wording has changed since the Feature's author made the claim, the claim has to be re-examined. Maybe the Feature still satisfies the rule; maybe not. Either way, a responsible audit trail demands that the re-examination be recorded, not silently skipped because a version bump happened to be "minor".

The contract semver encodes is irrelevant here. What matters is:

  1. Whether the wording of the Requirement has changed since the Feature's author wrote @Satisfies(...);
  2. What kind of change it was (cosmetic, structural, replacing, retiring);
  3. Who made it, when, and under what authority;
  4. Whether the Feature's claim of satisfaction should be refreshed in light of it.

Semver's three numbers answer none of those. A Requirement does not need three numbers. It needs a status, a history[], an optional refinement parent via @Refines, and a content hash that tells the drift detector whether anything structural has moved.

The four primitives

Before the chapter goes further, a minimal inventory of what is already in the package. Each of these has appeared in isolation in earlier chapters; this is the roll-call.

status: StatusesOf<S> — an abstract field on every Requirement<S>, with the legal values supplied by the Style's statusWorkflow.states. The DefaultStyle in packages/requirements/src/styles/default.ts defines the state set ['Draft', 'Approved', 'Implemented', 'Verified', 'Deprecated'] and a concrete transition table. A Requirement in the repo is always in one of these states, and the Style's transition table is the only legal path between them.

history?: readonly RequirementHistoryEntry[] — an optional append-only audit trail on every Requirement. The shape lives in packages/requirements/src/base.ts:150 as RequirementHistoryEntry { date, author, change, reason } and is enriched in packages/requirements/src/cli/types.ts:175 with an optional signature?: SignatureInfo for cryptographic sign-off. Append-only is not a convention — the build-time scanner refuses to publish a Requirement whose history[] shortens between commits.

@Refines(ParentReq, …) — the SysML-aligned class decorator in packages/requirements/src/decorators.ts:175 that links a child Requirement to one or more parents. This is a semantic link, not a TypeScript extends. The chapter returns to the distinction in section 8.

VersionInfo + VersionPin + detectVersionDrift() — the trio in packages/requirements/src/cli/versioning.ts that computes and compares content hashes. VersionInfo { version, contentHash, since, supersedes? } lives on a VersionedSpec. VersionPin { targetId, pinnedVersion, pinnedHash } lives on the Feature side. detectVersionDrift() is the function the compliance --strict gate calls to emit three kinds of drift: req-evolved, pin-stale, unpinned-req.

Four primitives. Composed, they do the work that semver does for a library and more — because the "more" is the audit trail, which is the whole point.

Status — the first FSM

The DefaultStyle's statusWorkflow is the Requirement's own state machine. It is intentionally small: five states, six transitions, one terminal state. Small enough that a non-engineer can read it; large enough that every stakeholder interaction with the Requirement has a home.

Diagram

Read left to right: a new Requirement starts Draft. Someone — a product lead, an ops lead, a legal counsel — signs off, and the Requirement moves to Approved. This is the moment Chapter 13's compliance --strict gate begins to care: from Approved onward, the gate demands that at least one Feature declare @Satisfies(ThisRequirement, ...). An unapproved Requirement is still under discussion; an approved one is a commitment.

The next transition, Approved → Implemented, is triggered by the Feature's code actually landing. This is not automatic in the current package — the developer is expected to flip the status field and append a HistoryEntry with change: 'StatusChanged' — but the event that motivates the flip is the @Satisfies Feature's tests turning green. Implemented means the rule is now a live claim the system is making about itself.

Implemented → Verified is the moment every AC on the satisfying Features has at least one @Verifies-bound test and the vitest suite exits clean. This is the status the regulators and auditors are looking for. A Verified Requirement is one the project can defend in an audit: not merely claimed to be delivered, but demonstrated to be delivered by concrete, runnable, currently-green tests.

Verified → Deprecated is the end. A Requirement reaches Deprecated when it is either superseded by a newer Requirement (section 12 below) or when its scope has been removed from the product. Deprecation is terminal — there is no path out of Deprecated, by design. An abandoned Requirement should stay abandoned; resurrection would happen through a brand-new Requirement with @Refines(DeadRequirement) or supersedes: 'DEAD-REQ', not through reopening the old one.

Two side edges exist for robustness. Approved → Deprecated handles the case where a Requirement was approved and then, before any implementation, is rejected — perhaps legal counsel noticed a conflict, perhaps the regulation it encoded was itself retracted. Draft → Deprecated handles the even earlier abandonment — a draft that turned out to be incoherent, or a duplicate of an existing Requirement. Both are dead ends, both are explicit, both are recorded.

The FSM is small because the surface of stakeholder interaction with a Requirement is small. There are not ten ways a rule changes its status; there are five. Writing down all five, and refusing transitions outside the five, is what makes the status a discipline rather than a free-text field.

The content hash

Versioning a library can afford to hash "the whole thing" — the whole published artefact — because every byte in the artefact matters to the caller. Versioning a Requirement cannot. A Requirement's specification has fields that matter to its satisfiability (the statement, the fitCriteria, the source, the risk, the kind) and fields that do not (the history[], the tracedTo link to a Jira ticket, the $schemaVersion bump when the package migrates). A change to a non-structural field must not trigger a drift alarm for every Feature whose VersionPin sits on this Requirement. Otherwise every trivial edit to an audit trail would wake the whole team.

The package draws the distinction in exactly one place. At the top of packages/requirements/src/cli/versioning.ts:74, a frozen list:

export const NON_STRUCTURAL_FIELDS: readonly string[] = Object.freeze([
  '$schemaVersion',
  '$schema',
  'history',
  'tracedTo',
  'version',
]);

Five field names. Every other field is structural. The canonicalize() function deep-clones the spec, strips keys whose name appears in this list (at every depth, defensively), sorts the remaining keys lexicographically, then JSON-encodes the result. That encoded string is the input to sha256. The output, prefixed sha256:, is the contentHash.

Diagram

The properties this buys are load-bearing for the rest of the chapter.

Determinism. Same structural content, same hash, on every machine, forever. Two developers on two laptops running computeVersionInfo() on the same spec get the same hash. CI does not produce a different hash from local. Re-running after a year still gives the same hash. The sha256 is a function of the canonical JSON, which is a function of the structural spec, and nothing else.

Cosmetic immunity. A developer on another team adds a tracedTo: [{ system: 'linear', ticket: 'ACME-481' }] entry to your Requirement. The tracedTo field is in NON_STRUCTURAL_FIELDS. It is stripped before hashing. The hash does not move. Your Feature's VersionPin remains valid. No false drift alarm. This is the right behaviour: the structural content of the rule has not changed, only the provenance index.

Structural sensitivity. A developer edits the Requirement's statement.response to fix an ambiguous phrase. The statement field is not in NON_STRUCTURAL_FIELDS. The hash moves. Every VersionPin pointing at this Requirement goes stale. The compliance --strict gate reports pin-stale for every Feature whose pinned hash no longer matches. The team is told: "this rule has moved structurally; please re-examine the claims that it is satisfied."

The design decision to enumerate five non-structural fields, rather than (say) hashing only the statement, is subtle and deserves a pause. A Requirement's structural content is not just its statement — it is also its fitCriteria, its source, its risk, its rationale, its kind. An edit to any of those is structurally significant: a looser fitCriteria changes what it means for a Feature to satisfy the rule; a weaker risk.level changes which Features must carry audit signatures; a different source changes the authority the rule derives from. The hash must move when those move. But it must not move when someone appends a historical entry or links a new Jira ticket. So the list is stated negatively: everything is structural except these five fields. Future additions to the spec shape default to structural — which is the safe default — and only explicit additions to NON_STRUCTURAL_FIELDS relax the drift alarm for new audit-adjacent fields.

Read in reverse, this is a policy statement: the audit trail, the external references, the schema metadata, and the version descriptor itself are not part of "what the rule says". Everything else is. It is a policy worth stating out loud because it is the only policy that produces the correct drift behaviour at scale.

The four ways to evolve a Requirement

With the content hash in hand, the package can distinguish four ways a Requirement can evolve over time. Each has a different effect on the pinned Features, a different effect on the history, and a different effect on the compliance --strict gate.

Diagram

Cosmetic. A typo is corrected in the rationale.claim prose. A new tracedTo: { system: 'jira', ... } link is appended because the requirement now has a ticket ID on the other team's side. The history[] array grows by one Reviewed entry after a quarterly re-read. In all three cases, canonicalize() strips the changed field (either explicitly, for history and tracedTo, or implicitly, because the edit is inside a non-structural descendant). The content hash is unchanged. No VersionInfo.version bump is needed. No HistoryEntry is required by the tooling — though one may still be voluntarily appended, because the audit trail is itself non-structural and an extra Reviewed entry is cheap insurance. The compliance --strict gate passes without noticing.

Cosmetic evolution is the common case. The overwhelming majority of Requirement edits in a mature repo are cosmetic: re-linking to a new ticket tracker, fixing a typo, appending a review note after a quarterly audit. A good versioning scheme does not alarm on these. The package's scheme does not.

Structural. The Requirement's statement.response is tightened to be more specific, because a regulator has clarified their guidance. The fitCriteria gains a new metric entry, because a quantitative threshold has been agreed. The risk.level is raised from Medium to High because an incident postmortem reclassified the rule. The source is updated because the standard reference has been revised (29148:2018 → 29148:2024). In every case, canonicalize() keeps the edited field; the sha256 changes; computeVersionInfo(spec, previous, clock) returns { version: previous.version + 1, contentHash: newHash, since: clock.nowIsoDate(), supersedes: previous.contentHash }. A HistoryEntry is appended — buildBumpHistoryEntry() in packages/requirements/src/cli/versioning.ts:238 refuses an empty reason, so every structural bump carries a human-readable justification. Every VersionPin that references this Requirement goes stale. The compliance --strict gate fails until each pin is either (a) refreshed to the new version, or (b) the Feature's ACs are revised to reflect the new wording and then the pin is refreshed. The choice is the developer's; both are legitimate, the point is that the choice is made explicitly rather than silently inherited.

Structural evolution is the interesting case. It is where the "did the world change?" question actually has to be answered. The tooling makes it impossible to ignore: the gate fails, the developer has to look at every pin, and the resolution is recorded.

Conflicting. A new regulation is passed that overrides an existing rule. The old REQ-NO-JS said "support readers with JavaScript disabled". The new REQ-NO-JS-v2 says "support readers with JavaScript disabled and readers using screen readers on constrained engines". The two rules are not refinements of each other — they are a replacement. The old one dies (status: Deprecated, reason: 'Superseded by REQ-NO-JS-v2 per WCAG 2.2 update'). A new Requirement is created with a new id. The new one's VersionInfo.supersedes points at the old content hash. Optionally, the new one @Refines the old one — but only if the old one is being kept as an abstract parent for historical continuity; in a true replacement, the old one is simply terminated and the new one stands on its own.

The supersedes field is distinct from the @Refines link in a way worth stating now, even though section 12 returns to it: supersedes records replacement in time, while @Refines records decomposition in meaning. A child Requirement that @Refines its parent says "I am a more specific version of what the parent said"; both parent and child remain live. A new Requirement that supersedes an old one says "I take the old one's place in the project's active commitments"; the old one dies.

Retired. The rule no longer applies. The product feature it governed has been removed from the roadmap. The regulation it mirrored has been repealed. Nothing replaces it; it simply no longer exists. The Requirement's status moves to Deprecated, a HistoryEntry with change: 'Deprecated' and a reason is appended, and the compliance --strict gate's orphan-approved-Requirement check no longer fires on it (because it is no longer Approved). The satisfying Features must be dealt with: either they are also retired, or they are re-aimed at a different Requirement they now satisfy, or they become orphans and fail the gate. There is no magic here — the gate's orphan check is per-Feature, not per-Requirement, and a Feature that loses its only satisfied Requirement becomes an orphan immediately.

Four branches. Each has a deterministic effect on the hash, the pins, the history, and the gate. Nothing in this tree is ambiguous. Contrast with semver, where the author of a library has to judge whether a given change is patch, minor, or major, and where "breaking change" is defined only relative to an unstated model of "what callers depend on". The Requirement tree is mechanical: either the hash moved or it did not, and if it moved, the structural fields that moved can be read off the diff.

VersionInfo + VersionPin — the drift sequence

The two types that hold the state are small. VersionInfo lives on the Requirement side:

export interface VersionInfo {
  readonly version: number;
  readonly contentHash: string;
  readonly since: IsoDate;
  readonly supersedes?: string;
}

Four fields. The version is a monotonically increasing integer (1, 2, 3, ...); the contentHash is the sha256 described in section 4; since is the ISO date the current version was computed; supersedes is either undefined (for version 1) or the previous version's content hash (for version 2 and later). The monotonic integer is not semver — there are no minor or patch sub-bumps. Either the hash moved or it did not; if it moved, the version is the old version plus one.

VersionPin lives on the Feature side:

export interface VersionPin {
  readonly targetId: string;
  readonly pinnedVersion: number;
  readonly pinnedHash: string;
}

Three fields. The targetId is the Requirement's id (e.g. 'REQ-NO-JS'). The pinnedVersion is the integer the Feature's author examined when they wrote @Satisfies. The pinnedHash is the sha256 of the Requirement at the moment the examination happened. Together, they are a contract between the Feature and the Requirement: "I, this Feature, claim to satisfy REQ-NO-JS as it stood at version 3, hash sha256:abc…". If the Requirement later moves, the claim is automatically renegotiated — the claim does not silently follow the Requirement.

A worked sequence makes the mechanics concrete. Consider the repo's lone existing Requirement, requirements/requirements/no-javascript.tsNoJavaScriptRequirement, id 'REQ-NO-JS'. A developer tightens its statement.response to reflect a WCAG 2.2 clarification. Here is what happens on the wire.

Diagram

The sequence rewards reading in detail. computeVersionInfo(spec, previous, clock) handles the version math — if the hash matches the previous, it returns the previous VersionInfo unchanged (a structural no-op); if the hash has moved, it bumps the version, records the previous hash as supersedes, and stamps the since date from the injected Clock port (no hidden Date.now(), no flakiness in tests). applyVersionBump() composes the VersionInfo computation with the HistoryEntry append, refusing to bump when ctx.reason is empty. The Clock injection is not decorative — the versioning module is pure, and its purity is tested at gate time with the property-based suite described in Chapter 13.

The detectVersionDrift() function is equally small. It iterates every (feature → requirementId) satisfaction edge, looks up the current VersionInfo on the Requirement, looks up the Feature's VersionPin for this Requirement, and compares. If they disagree, it emits a VersionDrift record with one of three kind values. The gate either fails (on req-evolved or pin-stale) or warns (on unpinned-req).

The two remedies shown in the sequence diagram are both legal. "Accept new version as-is" is the case where the developer reads the new statement, concludes the Feature still delivers it, and simply refreshes the pin to the new hash. "Amend ACs to match new statement" is the case where the new wording actually demands new or modified acceptance criteria — the pin refresh is accompanied by AC edits on the Feature. Either way, the choice is made explicitly and recorded in the git history; nothing happens silently.

The three drift kinds

detectVersionDrift() distinguishes three specific disagreements between a Requirement's current state and a Feature's pinned state. The distinctions matter, because the remedy is different in each case.

kind Precondition Severity Remedy
req-evolved pin.pinnedVersion < current.version AND pin.hash != current.hash FAIL Examine new version; refresh pin or amend Feature ACs; re-run gate.
pin-stale pin.hash != current.hash (even when versions match) FAIL Structural change mid-version; examine new hash; refresh pin or amend ACs.
unpinned-req Feature has no VersionPin AND Requirement's version > 1 WARN Feature authored against v1 but Requirement has since moved; author a VersionPin.

req-evolved is the normal case. The pinned version is strictly behind the current version — the Requirement has been bumped since the Feature's author wrote their @Satisfies. The gate fails. The Feature's author (or maintainer) must read the new version, decide whether the Feature still delivers it, and refresh the pin accordingly. This is the typical case that arises when a regulation is clarified, a risk is reassessed, or a statement is tightened. The version number going up (1 → 2, 2 → 3) is the explicit signal that "this is a later version of the same rule".

pin-stale is the subtler case. The pin's version number and the current version number agree — say both are 2 — but the hashes do not. This means the spec was edited and the version field was not bumped. In a well-disciplined repo, this should not happen: every structural edit should go through applyVersionBump(), which bumps the version monotonically. But enforcement is the gate, not the source. A developer might edit the spec's risk.level field by hand, forget to run applyVersionBump(), and commit the change. The pin's hash no longer matches even though the version did not move. The gate catches this. The pin-stale kind is effectively the gate's defence against version-bump-skipped edits; it is an honest-mistake detector.

In the code, the two are distinguished by a single conditional at packages/requirements/src/cli/versioning.ts:213:

const kind: VersionDrift['kind'] =
  pin.pinnedVersion < currentVersion ? 'req-evolved' : 'pin-stale';

If the pinned version is strictly less than the current, the gate reports the friendlier req-evolved — a normal evolution. Otherwise (versions equal, hashes different), the gate reports pin-stale — the more suspicious kind, where the version field was not updated to reflect the spec change.

unpinned-req is the weakest of the three. A Feature has no VersionPin at all for a Requirement it claims to satisfy; the Requirement's version is 2 or higher. This means the Feature was authored at a time when the Requirement was (presumably) at version 1, but no pin was recorded, and the Requirement has since moved. The gate emits a WARN rather than a FAIL, because the project may still be opting in to the version-pinning mechanism gradually — Features without pins are the legacy case, and raising them to errors is a migration step the team may not yet be ready to take. The remedy is to author a VersionPin pointing at the current version, explicitly acknowledging that the author has examined the current version.

A project that wants to turn unpinned-req into a FAIL does so through a config flag, not by editing detectVersionDrift(). The function itself is neutral; the severity mapping to gate-FAIL versus gate-WARN lives one level up, in the compliance report builder. This is the right split: the detector is pure and reports what it sees; the policy is configurable and lives where policy belongs.

@Refines — decomposition, not inheritance

The @Refines decorator is the SysML «deriveReqt» relation made syntactic. A child Requirement refines one or more parent Requirements; the child's statement is a more specific version of the parent's; both remain live. The canonical example in the sister repository's feature set is REQ-ACCESSIBILITY as an abstract parent that is refined by REQ-NO-JS, REQ-CONTRAST, and REQ-KEYBOARD.

Diagram

Read top-down: REQ-ACCESSIBILITY is an abstract rule — "every reader must be able to use the site regardless of their environment". Three child Requirements refine it. REQ-NO-JS specialises the rule for JavaScript-hostile environments. REQ-CONTRAST specialises it for visually impaired readers. REQ-KEYBOARD specialises it for readers without a pointing device. Each child has its own statement, its own fitCriteria, its own source citations. The parent remains a valid rule — it is simply more abstract than any of its children.

The crucial point: @Refines is not extends. TypeScript's extends is class inheritance, and the package does use it — NoJavaScriptRequirement extends Requirement because Requirement is the abstract base class that carries the eleven abstract fields every concrete Requirement must fill in. That is one kind of relation: the language-level inheritance that the type system enforces. @Refines is a different relation. It lives in the decorator registry populated by getRefinementLinks() at packages/requirements/src/decorators.ts:145, and what it records is a semantic link between two concrete Requirements that both already extends Requirement in the class-hierarchy sense.

This distinction matters enough to belabour. TypeScript extends inherits fields and methods. If A extends B, then A has all of B's fields and methods, and a value of type A is also a value of type B. SysML refine does not inherit fields. If ChildReq @Refines(ParentReq), then ChildReq has its own statement, its own fitCriteria, its own source — it does not borrow them from ParentReq. The child is about the same subject as the parent but says something more specific. That is not what inheritance means.

Another way to see the distinction: the class-level inheritance is used to narrow the generic Style parameter. When a project declares its own Style, the abstract class Requirement<S extends RequirementStyle> is instantiated at each of that project's Requirement subclasses with a specific S. The Style narrows StatusesOf<S> to the concrete state set, KindsOf<S> to the concrete kind set, and so on. This is what generics over an abstract base class are for. @Refines does none of that narrowing. It is orthogonal. A child and a parent @Refines-linked Requirement may perfectly well have identical Style parameters — indeed, they almost always do, because both are Requirements in the same project.

The practical consequence for the compliance --strict gate: a parent Requirement like REQ-ACCESSIBILITY does not need to be directly satisfied by any Feature, as long as its children are. The gate's orphan-approved-Requirement check is modulated by the refinement graph: if REQ-ACCESSIBILITY is Approved but has three @Refines-children that are each directly satisfied, the parent is considered indirectly satisfied and the gate passes. This is the graph's meaning. Without the @Refines link, the parent would look like an orphan. With it, the parent is an abstraction over its satisfied children.

The gate's treatment of refinement is not automatic in the current package — at the time of this writing, the orphan check is per-Requirement and does not walk the refinement tree upward. But the registry is in place, the decorator populates it on every import, and the next iteration of the compliance scanner will consume it. The decorator is load-bearing today for the traceability graph renderer; it is load-bearing tomorrow for the gate logic.

history[] — audit substitute for git blame

Every Requirement carries an optional history?: readonly RequirementHistoryEntry[] field. It is append-only. Every entry has four mandatory parts — date, author, change, reason — and an optional signature envelope for cryptographic sign-off. It is the one field in the spec that exists purely for the audit.

An engineer's first reaction to history[] is reasonable: why don't we just use git? git blame tells us who changed what line when. That is the audit trail. The answer is that git blame is structurally too shallow for the audit question. Git tells us that Stéphane Erard modified line 14 of no-javascript.ts on 2026-04-14 at 09:17 UTC, and the commit message says "fix typo". That is useful, and it is part of what an audit needs. But it is not enough to satisfy an auditor who is asking "why was this rule changed, under whose authority, and what was the cryptographic sign-off?". Commit messages are free-text. They are not typed. They are not enumerated. They cannot be filtered by change kind. They cannot be cryptographically signed in a form that a regulator's tool can verify against a public key registry.

Diagram

The diagram is a picture of the mismatch. Git blame and commit messages are a too-shallow layer for audit consumption — dotted line to the consumer, because the consumer can read them but has to do substantial interpretation work. The RequirementHistoryEntry[] is explicit — solid line, because the consumer reads the typed structure directly: enumerate all entries with change === 'Deprecated', filter by date >= '2026-01-01', aggregate by author, verify signatures against the public-key registry. Each of those operations is a one-liner against the typed history; against git blame they are ad hoc text processing.

The point is not that git history is useless — it remains the substrate, the ground truth of what bytes changed when. The point is that history[] is a projection of the subset of changes that matter to the audit, with typed enums, enforced non-empty reasons, and an optional cryptographic envelope. A good audit infrastructure has both layers. An audit infrastructure that has only git blame is asking regulators to read commit messages, which is not a scalable or honest answer.

The ChangeKind vocabulary

The change field on every history entry is typed. It is not a free-text string; it is a closed union. At packages/requirements/src/cli/types.ts:200, the enumeration:

export type ChangeKind =
  | 'Created'
  | 'StatusChanged'
  | 'StatementEdited'
  | 'AcAdded'
  | 'AcRemoved'
  | 'AcRenamed'
  | 'SatisfiedByChanged'
  | 'FitCriterionAdded'
  | 'FitCriterionRemoved'
  | 'RiskReassessed'
  | 'RationaleUpdated'
  | 'Deprecated'
  | 'Reviewed'
  | 'ApprovedBy'
  | 'SignedOff'
  | 'RejectedBy';

Sixteen kinds. The split is deliberate: each kind names a discrete event in the lifecycle of a Requirement, and every event is one the audit trail wants to enumerate independently. The free-text reason field carries the human-readable explanation ("tightened per WCAG 2.2 clarification"); the closed change field gives the tooling a tag to aggregate, filter, and report on.

The closed-set discipline pays off in reporting. A regulator asks: "show me every time a rule in this project was deprecated in 2026, who approved the deprecation, and what the reason was". The query over a typed history is a one-liner:

requirements history \
  --since 2026-01-01 --until 2026-12-31 \
  --change Deprecated \
  --include-signatures

The CLI walks every Requirement's history[], filters by change === 'Deprecated', filters by date, and prints a table. Every row has the Requirement id, the date, the author, the reason, and — if present — the signature. The query is fast, the output is structured, and the answer is defensible. Contrast with the equivalent over git commit messages, which would require a regex over 18 months of commits, a manual pass to classify each match as actually-a-deprecation-or-not, and a judgement call on which commit message represents the "official" deprecation decision when multiple touches exist.

A note on extensibility: the ChangeKind union is closed at the package level, but a project's own change kinds — say, 'ArchitectureReviewPassed' or 'LegalCounselSignoff' — can be added by extending the union at a downstream layer. The package ships the common kinds; specialised projects augment. The closed-set discipline is preserved because each downstream extension is still a finite union — no free-text kinds, no wild card fall-backs.

Cosmetic-immune hashing at scale

The non-structural-fields list earns its keep the moment more than one team touches a Requirement. In a large codebase, a single Requirement is frequently a shared rule that multiple teams care about. Team A authored it. Team B added a tracedTo link to their Jira ticket. Team C added a history entry after a quarterly re-read. Team D updated the $schemaVersion because the package migrated from schema v1 to v2. None of those changes altered the rule itself.

In a naive hashing scheme — where every spec edit triggers a new hash — every one of those touches would fire the pin-stale drift detector for every Feature pinning this Requirement. A cross-team Requirement might be pinned by 30 Features across 8 repositories. Each cosmetic touch would wake 30 teams. The gate would either become a nuisance (and be bypassed) or the teams would stop making cosmetic improvements (and the audit trail would rot).

Diagram

The decision tree is small because the policy is small: five fields ($schemaVersion, $schema, history, tracedTo, version) are non-structural; everything else is. An edit inside any one of them, or nested under any one of them, is cosmetic. Everything else bumps the hash. The tree has no grey areas.

At scale, this is the difference between a drift detector that teams use and a drift detector that teams disable. A detector that raises on every edit is noise; a detector that raises only on structural edits is signal. The package chooses signal.

A worked contrast: the same worked example from section 9, extended. Team D adds $schemaVersion: 2 to the spec object because the package migrated. In the naive scheme, the hash moves, all 30 Features pinning this Requirement go stale, 30 teams get drift alarms in their next CI run, and 30 teams spend an afternoon re-examining claims that were already valid. In the package's scheme, $schemaVersion is in NON_STRUCTURAL_FIELDS, the canonicalize() pass strips it before hashing, the hash is unchanged, the 30 pins remain valid, and Team D's schema migration is a cosmetic no-op from the drift detector's point of view.

The same logic applies to history itself. An audit consumer who appends a Reviewed entry to a Requirement's history is not altering the rule — they are adding a record that the rule was examined. The record matters for the audit; it must not be noise for the drift detector. By listing history in NON_STRUCTURAL_FIELDS, the package makes the audit trail free to grow without triggering drift alarms. The audit is cheap; the drift is precise. Both are loud where they should be and silent where they should be.

supersedes and supersession chains

The VersionInfo.supersedes field is a second backward link that @Refines does not provide. supersedes records this VersionInfo replaces that previous hash. The field is populated automatically by computeVersionInfo() every time the hash moves:

if (previous) {
  return {
    version: previous.version + 1,
    contentHash,
    since: clock.nowIsoDate(),
    supersedes: previous.contentHash,
  };
}

One line in the versioning module; a load-bearing line for supersession chains. Every structural evolution of a Requirement builds a backward chain: v3's supersedes points to v2's hash; v2's supersedes points to v1's hash; v1's supersedes is undefined. A requirements trace supersessions REQ-NO-JS command walks the chain, printing v3 supersedes sha256:b7…, which superseded sha256:a3…, which was original (v1).

This is distinct from @Refines in exactly the way stated in section 8: supersession is replacement in time, refinement is decomposition in meaning. A worked example makes the contrast sharp.

Suppose the original NoJavaScriptRequirement (call it REQ-NO-JS-v1) had a vague statement: "the system shall render without JavaScript". WCAG 2.2 is published with a clarification that distinguishes "full textual content and internal navigation" from "all enhancements". The vague v1 is now misleading — it over-promises, covering cases the rule never intended. The team decides to replace it with a tighter v2: "serve every page's full textual content and internal navigation from a static HTML baseline, independently of JavaScript execution" (which is, in fact, the real no-javascript.ts statement).

Two design choices are open to the team. Choice A: evolve the same Requirement. Edit the statement in place. The hash moves. VersionInfo.version goes from 1 to 2. supersedes on v2 points to v1's hash. No new Requirement id is created. Features previously pinned at v1 get req-evolved drift; they update their pins to v2. This is the common case when the rule is being tightened rather than replaced.

Choice B: create a new Requirement. Keep REQ-NO-JS-v1 as a deprecated artefact for historical continuity; author a REQ-NO-JS-v2 with a new id, new statement, new VersionInfo where supersedes points at the v1 hash. REQ-NO-JS-v1 goes to status: Deprecated. Features previously satisfying REQ-NO-JS-v1 must explicitly re-aim at REQ-NO-JS-v2 — the satisfies list on every Feature must be edited. This is the heavier path, appropriate when the rule has actually changed identity rather than tightening in place.

The test for which choice applies is whether a reader of the audit trail would understand v1 and v2 as the same rule in two stages of precision (Choice A) or two different rules, one replacing the other (Choice B). In practice, Choice A is overwhelmingly the common case for normal evolution; Choice B is reserved for genuine replacements, where the old id has to stay deprecated because existing external references point at it.

An auditor walking the supersession chain in Choice A reads: "REQ-NO-JS v2 is the current version; it superseded v1 on this date; here is the HistoryEntry explaining why". An auditor walking the chain in Choice B reads: "REQ-NO-JS-v1 is deprecated, was superseded by REQ-NO-JS-v2 on this date; REQ-NO-JS-v2 is currently Approved". Both are legible. The difference is whether the id stays or changes.

The @Refines relation does not change under any of this. If REQ-NO-JS (in Choice A) refined REQ-ACCESSIBILITY, it still does — the refinement relation is between class identities, and the class identity is stable across VersionInfo bumps. If REQ-NO-JS-v1 refined REQ-ACCESSIBILITY in Choice B, REQ-NO-JS-v2 might also @Refines(AccessibilityRequirement) — the parent relation is authored per-class, not inherited through supersedes.

The two FSMs — interaction

The scenarii-and-three-FSMs chapter earlier in the series describes the three FSMs the scenarii player composes: the project lifecycle FSM, the per-Feature FSM, and the per-AC FSM. The project lifecycle FSM is the top-level one, declared at packages/requirements/src/cli/scenario/project-lifecycle-fsm.ts. Its states are:

export type ProjectLifecycleState =
  | 'Empty'
  | 'Seeded'
  | 'RequirementsDrafted'
  | 'FeaturesDerived'
  | 'ScaffoldingReady'
  | 'Implementing'
  | 'Verifying'
  | 'Published'
  | 'Failed';

Eight states plus Failed as the universal error sink. The Requirement's own status FSM (Draft → Approved → Implemented → Verified → Deprecated) progresses independently, but it is gated by where the project lifecycle FSM currently is. You cannot Approve a Requirement before the project reaches RequirementsDrafted; there is nothing to approve if the project is still Empty or Seeded. You cannot @Satisfies-link a Feature at all before the project reaches FeaturesDerived — there are no Features before that state. You cannot mark a Requirement Verified before the project reaches Verifying — the verification phase has not begun.

Diagram

The two FSMs do not share state; they share constraints. The project FSM governs when a Requirement can transition; the Requirement FSM governs what the Requirement can transition to. A project at Empty has zero Requirements — the Requirement FSM has no instance yet. A project at RequirementsDrafted has Requirements in Draft that can move to Approved; the project-level transition has created the condition for Requirement-level transitions. A project at Verifying has Requirements in Implemented that can move to Verified — again, the project-level gate unlocks the Requirement-level move.

This two-tier gating is the machinery that keeps the whole lifecycle coherent. Without it, a developer could mark a Requirement Verified on day one of the project, before any Feature existed, before any test had been written. The project FSM says: you cannot reach Verified until I reach Verifying, and I cannot reach Verifying until all Features' ACs are in place. The composition of the two FSMs is the reason the word Verified means something — it is not self-asserted; it is earned by the project's arrival at the phase where verification is actually happening.

The implementation in project-lifecycle-fsm.ts is pure — no I/O, no side effects, only state + transition table + dispatch() method that throws on invalid transitions. The orchestrator that drives side effects (scaffolding, test generation, compliance runs) lives one layer up and consults the FSM's currentState() before each operation. The FSM is a validator, not a do-er. The same discipline applies to the Requirement status FSM: the Style's statusWorkflow.transitions table enumerates the six legal moves, and any attempt to transition outside the table is rejected at the API boundary.

Composing the compliance --strict gate

The compliance --strict gate described in Chapter 13 has historically checked three conditions: orphan Features (concrete Features without @Satisfies), orphan approved Requirements (Approved Requirements without any @Satisfies satisfier), and under-covered Critical ACs (Priority.Critical ACs without a @Verifies test). With the lifecycle discipline in hand, the gate composes two more checks drawn from detectVersionDrift():

  • Evolved without pin refresh (req-evolved drift) — the Requirement has bumped past the Feature's pinned version. FAIL.
  • Stale pin at same version (pin-stale drift) — the Requirement's hash has moved without a version bump (an undisciplined edit). FAIL.
  • Bumped Requirement without any pin (unpinned-req drift) — the Requirement is at version 2 or higher; the Feature has no pin at all. WARN in the default policy; FAIL under a stricter config.

Under --strict, the gate exits 0 if and only if all five conditions hold:

gate: PASS  ↔
    orphan Features       === 0
  ∧ orphan Approved Reqs  === 0
  ∧ under-covered Crit    === 0
  ∧ req-evolved drifts    === 0
  ∧ pin-stale drifts      === 0

The AND composition matters. The five conditions are independent — a project can pass the Satisfies check while failing on drift, or pass drift while failing on AC coverage. The gate treats them as coequal preconditions. Any failure, in any condition, fails the gate. The exit code of compliance --strict is the single boolean: all-pass or some-fail.

The unpinned-req warning is deliberately not in the AND composition. Its severity is one notch below the FAIL conditions because it represents a migration case — a Feature that was authored before the versioning mechanism was in place. The gate reports it, the report includes a remedy ("author a VersionPin"), but the gate does not fail on it by default. A project that wants to close the migration hole flips a config flag and promotes the warning to a FAIL. Defaults are chosen for the common case; the common case is gradual adoption of versioning; the strict case is a config knob away.

The cost of the five composed checks is worth naming. The AST extraction phase from Chapter 13 is already doing a single-pass walk of the source tree. The drift detection is a flat iteration over (feature → satisfies → requirement) edges with two hashmap lookups per edge — O(edges), cheap. The total extra cost of lifecycle gating on top of the traceability gating is a small constant factor. Gates that are fast get run. The lifecycle gate is fast.

Audit trail as first-class citizen

A Requirement without history[] is a Requirement without accountability. The field is optional in the type because the package supports gradual adoption — a project migrating from typed-specs into @frenchexdev/requirements will initially author Requirements with no history, and that is tolerable for the first pass. But the moment a project enters any regulated domain — SIL-rated (IEC 61508), SOC-2 (AICPA), ISO 27001, FDA-regulated medical software, DO-178C avionics, EU AI Act — the optional becomes mandatory. An auditor reading the project's Requirement set will ask, about every rule:

  1. Who approved this rule? Under what authority?
  2. When was it last reviewed? By whom?
  3. What changes have been made to it, and why?
  4. Is there a cryptographic sign-off from a signing authority, verifiable against a published public key?

A commit history does not answer these — not because git is inadequate, but because the questions are about the rule, not about the source file. They are Requirement-level questions, and the answers belong in a Requirement-level structure. The history?: readonly RequirementHistoryEntry[] field is where those answers live. The signature?: SignatureInfo envelope — the optional cryptographic sign-off at packages/requirements/src/cli/types.ts:190 — is where the fourth answer lives, when the domain demands it.

The regulators do not care what commit hash a change happened in. They care who approved it, when, under what authority, and whether that authority's sign-off can be verified. The HistoryEntry gives them the first three; the SignatureInfo envelope (Tier-3 cryptographic verification, shape-validated today by validateSignatureShape in packages/requirements/src/cli/audit-hooks.ts) gives them the fourth. Together, they are the substrate that makes a TypeScript-typed Requirement legally useful in the industries that demand it.

The broader claim, which the chapter ends on: versioning a normative artefact is not an API-compatibility problem. It is an audit-trail problem. Semver solves the API-compatibility problem well, for the thing it is for; semver applied to a Requirement is a category error that produces either false alarms or silent drift, depending on how the author judges each edit. The four primitives the package ships — status, history[], @Refines, and the VersionInfo/VersionPin pair with cosmetic-immune hashing — solve the audit-trail problem. They compose under compliance --strict. They fail loudly on structural drift. They stay silent on cosmetic churn. They record every evolution with a typed change kind, a non-empty reason, and optional cryptographic sign-off.

That is what lifecycle discipline looks like when the artefact being versioned is a rule. The numbers 2.3.1 do not appear. Nothing is patch, minor, or major. Requirements do not compile. They just bind.

What this chapter took from the files

What comes next

The next chapter returns from lifecycle to traceability, and examines how the typed-fsm package was extracted from the CV repository to give every Style's statusWorkflow a runtime FSM with the same declarative guarantees as the in-code pipeline FSM. The primitive is the same — a transition table, a dispatch() method, a pure validator — but the consumer is different: it is the Requirement's status field, not the project's lifecycle phase. The machinery composes.

⬇ Download