Chapter 22f — The Full Picture: End-to-End Discipline
Three chapters of mechanism. This chapter is the mechanism composed — what it feels like, in practice, when the whole thing runs.
Chapters 22c, 22d and 22e each gave you a mechanism and stopped there. Chapter 22c pulled the requirements surface apart into bundles and rebuilt the shared kernel with composeStyle(). Chapter 22d replaced a single status field with a five-piece lifecycle — status, history[], @Refines, VersionInfo, VersionPin — and introduced detectVersionDrift as the runtime auditor. Chapter 22e widened the lens to the DSL itself and argued that the wire format has its own clock: a date-stamped $schemaVersion, additive-only Style evolution, codemod migrations, a library semver that walks alongside — but never conflates with — the schema date.
Three chapters, three axes of versioning: the spatial axis (modularity), the temporal axis of a single artefact (lifecycle), and the temporal axis of the language itself (schema). Each axis, taken alone, is a moving part. The synthesis — the thing this chapter is about — is how those three axes behave when a single change has to propagate through all three at once.
What follows is not a recap. It is a practice. A ten-step discipline that, if you follow it, keeps a requirements codebase coherent through the combined pressure of bundles growing, Requirements evolving, and the schema itself bumping underneath. The discipline is not external policy bolted on top of the types; every step is the observable shadow of something the type system already enforces. The compiler carries most of it. The compliance --strict gate catches the rest. This chapter's job is to make the shape of the whole thing legible in one page.
The word discipline, in this chapter, does not mean rules imposed from outside. It means the pattern of behaviour the tools produce when used as intended — the path of least resistance, the thing you do when you are not trying to do anything clever. The ten items below are, to a first approximation, a description of what this codebase looks like when the types and the gate have been engaged for long enough that their shape has become the author's shape too. Reading them as a list of injunctions misses the point; reading them as a description of a steady state is closer. The steady state is what the mechanism wants you to converge on. This chapter spells out what that convergence looks like, what it costs to reach, and — crucially — what happens when you diverge from it and the gate catches you.
What the prior three chapters built
Three short paragraphs, one per chapter, to fix the vocabulary we will use for the rest of the synthesis.
Chapter 22c — modularity. The argument was that a single requirements/ directory at the repo root does not scale past a few dozen Requirements and a handful of client projects. The remedy was three bundles (@frenchexdev/requirements, @frenchexdev/requirements-platform, @frenchexdev/requirements-ssg), a shared kernel carrying the base types, the DefaultStyle and the decorator surface, and — as the load-bearing primitive — composeStyle(DefaultStyle, { ssgOverrides }). A project picks its bundle, composes its style, and inherits the kernel for free. The three-step migration (extract kernel → carve platform → carve SSG) was the cost of getting there; the long-term win was bundles that can be versioned, published, and broken-glass-unpublished independently. Modularity is the axis in which a Requirement has a home. The bundle a Requirement lives in carries metadata that the flat-directory approach could not: who can extend it, who is expected to reuse it, and what downstream projects own the obligation to keep satisfying it as they evolve. Those three pieces of metadata are invisible when everything lives in a single folder; they are first-class when the bundle split is real.
Chapter 22d — lifecycle. A Requirement is not a frozen artefact. It has a status (one of the Style's lifecycle states, e.g. Draft → Proposed → Approved → Implemented → Deprecated → Archived), a history: readonly HistoryEntry[] append-only audit trail, a @Refines(ParentReq) edge when it decomposes another Requirement, a VersionInfo { version, contentHash, since, supersedes? } carrying the monotonic counter + sha256 of its canonical form, and — on the satisfier side — a VersionPin { targetId, pinnedVersion, pinnedHash } declaring what version of the Requirement a Feature was built to. detectVersionDrift(features, requirements, pins) compares each pin to the current requirement hash and emits one of three drift kinds (req-evolved, pin-stale, unpinned-req). The four ways to evolve a Requirement — amend with version bump, refine into a child, deprecate with supersedes, withdraw — each have a type-level signature and a mandatory history entry. Lifecycle is the axis in which a Requirement has a past. The canonical form that the content hash operates on is not the raw JSON of the spec; it is the spec with $schemaVersion, $schema, history, tracedTo and version stripped, then with all keys sorted recursively (see the NON_STRUCTURAL_FIELDS constant at packages/requirements/src/cli/versioning.ts line 74). The effect is that cosmetic changes (reordering keys, editing the tracedTo external-link list, editing the history after the fact) do not bump the version, but any semantic change does. This is a non-trivial design decision: it means the hash is a fingerprint of meaning, not of bytes.
Chapter 22e — schema evolution. The DSL itself changes. New required fields appear; new kinds are added; old ones deprecate. Every spec carries a first-class $schemaVersion: '2026-04-14' field (an ISO date, not a semver triple). Style evolution is additive-only inside a schema date — you may add an entry to requirementKinds, never remove one. Breaking changes coin a new date. For every new date, the package ships a codemod (requirements migrate --from 2026-04-14 --to 2026-07-01) that rewrites consumer specs in place. The library itself has a parallel semver (0.3.0 → 0.4.0) that moves for code-level breakage — CLI flags, TypeScript API surface, import paths — independently of the schema date. Two clocks, two versioning systems, no conflation. Schema is the axis in which the language itself has a past. The decision to use an ISO date rather than a semver triple for the schema is motivated by the observation that schema versions are about moments in time — "the format as of 2026-04-14" — rather than about compatibility tiers. Semver (major.minor.patch) tries to encode a compatibility contract; dates make no such promise and instead let the codemod registry, not the version string, encode the compatibility relation. This decouples the when from the how: a consumer on schema 2026-04-14 knows it needs to migrate to 2026-07-01, looks up the migration in the registry, and runs it. No version-math reasoning required.
These three axes are not independent. A lifecycle event (Chapter 22d) can trigger a bundle re-home (Chapter 22c); a schema bump (Chapter 22e) can force a batch lifecycle update (Chapter 22d) across every Requirement in every bundle (Chapter 22c). The synthesis is that the three mechanisms compose into a single discipline — ten steps, laid out below.
The master end-to-end flow
The diagram below is the chapter's north star. It names every node in the traceability chain, every decorator-induced edge, every compliance-gate edge, and the fact that the whole thing is bordered at the top by a stakeholder (the source of the obligation) and at the bottom by the compliance gate (the mechanism that refuses to let a broken graph ship).
Read the diagram from top to bottom as a temporal process, and the dashed edges leading to the gate as a topological audit run in parallel.
The authoring path — solid arrows — runs: a stakeholder (a human, a regulation, a contract, a standard, a risk assessment, an incident report, a business goal, a domain expert) raises an obligation. An author transcribes that obligation into a Requirement subclass, with its typed statement, rationale, fitCriteria, source, risk and its lifecycle status initially set to Draft. Potentially, the Requirement is decomposed into children via @Refines(ParentReq) — each child a more specific obligation, each edge a typed arrow in the registry. Features are authored against Approved Requirements via @Satisfies(Req1, Req2, ...), declare their ACs as abstract methods returning ACResult, and carry a versionPins table declaring the VersionPin { targetId, pinnedVersion, pinnedHash } of each Requirement they were built to. Tests are authored with @FeatureTest(Feat) on the class and @Verifies<Feat>('acName') on methods, exactly mirroring the Feature's AC names. Source code implements the Features. Both Requirements and Features serialise to JSON on-the-wire and are validated against the JSON Schema files under packages/requirements/schemas/ (the schema files carry the same date-stamp as the specs' $schemaVersion).
The audit path — dashed arrows — runs in parallel. compliance --strict walks the AST, rebuilds the registry, and runs five gap-detection passes. Pass one: are there orphan Features (any non-abstract Feature class with an empty @Satisfies list)? Pass two: are there Approved Requirements without a satisfier (any status: 'Approved' Requirement not appearing in any Feature's @Satisfies)? Pass three: are all Critical-priority ACs covered by at least one @Verifies edge? Pass four: does detectVersionDrift(features, requirements, pins) return an empty list (i.e., no stale pins, no evolved Requirements without a matching pin bump)? Pass five: does every spec's $schemaVersion match the schema file the tooling is currently running against? The exit code is the AND of the five. If any fails, the CLI writes a structured diff to stdout, emits requirements/compliance.json, and returns 1. The push wrapper aborts.
The diagram shows two things at once: the what (the typed graph of specs) and the whether (the gate that declares whether the graph is currently valid). The next ten sections — the ten disciplines — are the instructions for keeping the dashed arrows always green.
Discipline 1 — Write Requirements first
The project lifecycle FSM in packages/requirements/src/cli/scenario/project-lifecycle-fsm.ts is not a diagram in a design doc. It is an eight-state, strictly-ordered state machine with a declarative transition table at lines 40–60: Empty → Seeded → RequirementsDrafted → FeaturesDerived → ScaffoldingReady → Implementing → Verifying → Published, with Failed as the terminal basin. The transition from RequirementsDrafted to FeaturesDerived happens on the DeriveFeatures event. The orchestrator refuses to fire that event unless the project has at least one Approved Requirement; the FSM itself is pure logic, but the wrapper scenario enforces the guard. This is not decorative — it is mechanical. Chapter 22d's @Refines decomposition presumes a parent Requirement to refine, and chapter 22c's composeStyle(DefaultStyle, { ssgOverrides }) presumes a concrete Requirement to bind the project's vocabulary against. Writing Features before Requirements is not a stylistic faux-pas; it is a category error the FSM catches at the orchestration layer. Every project in the @frenchexdev/* monorepo starts at state Empty, loads its scenario (LoadScenario → Seeded), generates Requirements (GenerateRequirements → RequirementsDrafted), and only then derives Features. The ordering is not a preference; it is the shape of the pipe.
The practical consequence for day-to-day work is that when a developer opens a feature branch and starts to hack, the first artefact they author is the Requirement — not the Feature subclass, not the test file, not the implementation. A developer who skips the Requirement and dives into the Feature triggers, on the next compliance --strict, the orphan Feature gate (a Feature that @Satisfies nothing, or that @Satisfies a Requirement that does not exist). The error message points them back at the missing Requirement; they author it retroactively; the push succeeds. In practice, the developer learns in two or three such cycles that authoring the Requirement first is the shorter path. The discipline becomes muscle memory through the gate's feedback, not through any conscious act of policy adherence. This is the general pattern of all ten disciplines: the gate teaches, the types enforce, the developer's habits converge on the compliant shape.
Discipline 2 — Classify each Requirement's origin
Every Requirement subclass carries a source: RequirementProvenance field declared in packages/requirements/src/base.ts around line 86. The type is a discriminated union. DefaultStyle's sourceKinds enumerates the eight legitimate provenances: stakeholder, regulation, standard, contract, risk, business-goal, incident, domain-knowledge. The Style's validator narrows source.type to those eight at compile time when the Style is declared const. A Requirement whose source is none of the eight — or, more commonly, a Requirement that cannot comfortably claim any of the eight — is almost always a Feature in disguise. Something engineering wanted to build, dressed up in the language of obligation. The litmus test is blunt: if you remove this Requirement, does the business notice? If the answer is no, it is a Feature, not a Requirement. If the answer is yes, you must be able to point at the stakeholder, the regulation, the standard, the contract, the risk register entry, the OKR, the post-mortem, or the domain expert's transcript — and fill in the corresponding source slots. The source field is the one slot in the Requirement type that refuses to let engineering sneak its own agenda into the Requirements layer. That refusal is the whole point.
The eight source kinds are themselves the distillation of a careful reading of IEEE 29148, Volere, and the ISO 25010 quality model. They are not arbitrary. stakeholder is the Volere concept of a named human whose interest the Requirement serves. regulation is a legal text — GDPR, HIPAA, KYC — with a statute and a section. standard is an industry standard — WCAG, ISO, IEC, IEEE — with an identifier and a clause. contract is a bilateral agreement — a Master Service Agreement, a customer contract, a supplier contract — with parties and clauses. risk is an entry in a risk register, traceable to a date and a probability/impact assessment. business-goal is an OKR, a strategic plan, a business-model commitment. incident is a post-mortem with a date and a timeline. domain-knowledge is a transcript or note from a domain expert, with a name and a date. Each of the eight has a distinct set of mandatory slots the Style declares and the type system enforces; a regulation source without a statute number fails at parse time. The discipline of classifying source is therefore not only about honesty (not dressing up engineering wishes as business obligations); it is also about completeness — each source kind demands specific provenance data, and that data is what makes the Requirement auditable.
Discipline 3 — Compose the Style; don't hand-copy
Chapter 22c introduced composeStyle(DefaultStyle, { ...ssgOverrides }) as the load-bearing primitive for bundle independence. The composition merges two vocabulary objects (DefaultStyle's ontology plus the project's local additions) into a single Style, and — crucially — retains referential identity with the kernel's DefaultStyle for the unaltered slots. When a new requirementKind is added to DefaultStyle's vocabulary in the shared kernel (@frenchexdev/requirements) on Monday, every project that runs composeStyle(DefaultStyle, ...) inherits the new kind on Tuesday's rebuild without any change to the consumer's code. Contrast this with hand-copying DefaultStyle (a temptation when a consumer wants "just one extra field" and naively spreads the DefaultStyle object): the copy creates a fork. The fork drifts silently from the kernel. A new kind added upstream never propagates. Worse, a removal upstream (when additive-only Style evolution eventually relaxes — see chapter 22e) leaves the fork with a ghost kind that fails no current test. Hand-copying is the anti-pattern. composeStyle is the only blessed way to extend the DSL's ontology inside a consumer. The composeStyle name is not cosmetic; it is a promise about identity preservation that the type system checks and the build reinforces.
The deeper reason composeStyle matters is that it lets the DSL's vocabulary evolve without every downstream consumer needing to be updated in lockstep. The alternative — every project carries its own Style file, forked from DefaultStyle at some point in the past — produces a maintenance nightmare: upgrading the shared kernel becomes a project-by-project merge conflict, with dozens of places where the forked Style has drifted from the kernel's current state. composeStyle inverts that topology: the project carries only its differences from DefaultStyle, and the common parts live in exactly one place. A new requirementKind added upstream is picked up automatically by every project that composed rather than forked; a project that wants the old behaviour can always pin its dependency on the older kernel version. The composition primitive is therefore the equivalent, for DSL vocabularies, of what extends is for class hierarchies — except that it is flat-merged rather than delegated, which is the right shape when the parent is a data ontology rather than a behavioural contract.
Discipline 4 — Refine, don't duplicate
SysML distinguishes refine, derive, satisfy and verify — four different relations with four different typed edges. The @Refines(ParentReq) decorator on a Requirement subclass is the SysML refine relation: the child Requirement is a more specific version of the same obligation, not a separate obligation. The parent remains. It does not become redundant. The registry holds both. An author facing a broad Approved Requirement ("the site must render without JavaScript") who sees that a narrow subcase needs explicit treatment ("the screen-reader accessibility mode must render without JavaScript even when the parent site has JavaScript enabled as an accessibility removal toggle") has two choices. The tempting one: copy-paste the parent, edit the clauses, save as a new file. The correct one: @Refines(NoJavaScriptRequirement) on a new child class whose statement narrows the parent's, and whose rationale.claim explains the narrowing. Duplication yields two Requirements with no relation in the registry; the compliance tool cannot tell one is a refinement of the other. @Refines yields a typed parent-of edge that the compliance tool walks to detect orphan children (a child @Refines something that doesn't exist), conflicting statements (child's statement contradicts parent's), or redundant refinements (child is structurally identical to parent, i.e. a hash equality). The typed edge is not paperwork; it is the only thing that lets the registry tell you what is actually going on.
The registry benefit of @Refines is visible in the trace chain <req-id> CLI subcommand: given any Requirement id, the command walks up the refinement chain to the root obligation and down to every concrete Feature and Test that eventually satisfies and verifies it. A duplicated Requirement shows up as an island: a node with no parent, no children, and a statement suspiciously similar to some other Requirement in the graph. The compliance trace gaps detector flags such islands in its report. @Refines, by contrast, produces a tree the tool can walk top-down or bottom-up, and from which it can generate the SysML-style traceability matrix for regulatory deliverables. The discipline is therefore also a precondition for the deliverable side of the project: if you want to hand a trace matrix to an auditor, you must have authored the @Refines edges; the matrix cannot be reconstructed from duplications.
Discipline 5 — Satisfy, don't inherit
Here the distinction is between TypeScript mechanics and registry semantics. extends is TypeScript inheritance: it gives a subclass its parent's fields and methods, it narrows the type hierarchy, it is single. @Satisfies(Req1, Req2, ...) is a decorator: it registers a typed edge from the Feature class to every Requirement class in the argument list, it changes no runtime behaviour of the Feature class, it is many-to-many. The two serve opposite purposes and the temptation to conflate them is real. A developer, wanting to express "this Feature is a kind of No-JS Feature", writes class ProgressiveEnhancementBaselineFeature extends NoJavaScriptRequirement. This is wrong in three ways at once. First, it couples the Feature to the Requirement's implementation fields, which the Feature has no need for. Second, it destroys the many-to-many relation: the explorer TUI feature satisfies three requirements at once (see the chapter 00 worked example — ReqDiscoverableTraceabilityRequirement, ReqDogFoodRequirement, ReqParallelDeliverableRequirement); extends permits exactly one parent. Third, it makes the Requirement a parent in the TypeScript type system, which confuses every downstream piece of tooling that walks the class hierarchy. The decorator-based @Satisfies keeps the Feature class flat, lets it satisfy N Requirements, and produces exactly the edge the registry needs. extends for TypeScript inheritance, @Satisfies for registry relations — never mixed.
The type-system argument for this separation is worth lingering on. TypeScript's single-inheritance rule (inherited from ECMAScript class semantics) would, if we used extends for Requirement linkage, force an awkward choice: either the Feature can satisfy only one Requirement (wrong shape for real-world work, where most Features satisfy between two and five), or we would need a second mechanism for the "additional" Requirements — producing an inconsistent API where one Requirement is special and the others are not. The decorator approach side-steps this entirely: every Requirement in a @Satisfies list is on equal footing, the argument list is ordered (the order is preserved by the registry and rendered in the compliance report, which gives authors a cheap way to mark "primary" versus "secondary" satisfaction without needing a separate field), and the number of Requirements is open-ended. TypeScript's decorator metadata, with the keyof T generic constraint on the decorator factory, ensures that every class listed is a Requirement subclass at compile time — so the mechanism has the same type-safety guarantees as extends would have provided, without the single-parent limitation. The decorator is therefore a strict superset, not a substitute.
Discipline 6 — Pin versions explicitly
A Feature that does not carry a VersionPin for each Requirement it satisfies is a Feature that can silently drift. The pin shape is declared in packages/requirements/src/cli/versioning.ts lines 40–44 as { targetId: string; pinnedVersion: number; pinnedHash: string; }. The pinnedHash is the sha256 of the Requirement's canonical form at the moment the Feature was built against it. When the Requirement's statement later tightens (an author amends rationale.claim, or narrows statement.response, or adds an assumption), the Requirement's canonical form changes, its contentHash changes, its VersionInfo.version increments. detectVersionDrift compares the Feature's pinnedHash to the Requirement's currentHash and, if they differ, emits either req-evolved (pin version strictly less than current version) or pin-stale (same version but different hash — a rare and alarming case indicating an out-of-band edit). Without the pin, the Feature quietly inherits whatever the Requirement now says, and nobody notices that the thing the Feature was originally built against has become something else. compliance --strict treats any non-empty drift list as a gate failure. The pin is the check. The check is cheap to write (one line per Requirement) and ruinous to omit.
The third drift kind unpinned-req is worth a word: it fires when a Feature satisfies a Requirement without any pin, and that Requirement has evolved past v1. A Feature that satisfies a v1 Requirement without a pin is, by policy, fine — the implicit pin is "whatever the current version is". But once the Requirement bumps to v2, an unpinned Feature is a liability: did the author consciously decide the Feature still satisfies the new statement, or did they just never look? The unpinned-req warning surfaces precisely this question. A developer seeing the warning has two ways to silence it: pin to the current version (affirming "yes, I've reviewed the v2 statement and the Feature still satisfies it"), or pin to an earlier version and then consciously upgrade after reviewing the AC list. Either resolution requires the developer to look, which is the whole point of the check. An unchecked Feature is a Feature nobody has looked at since v1, and that is the Feature most likely to be silently wrong.
Discipline 7 — Append history, never mutate silently
Every Requirement and Feature carries a history: readonly HistoryEntry[] append-only audit trail, typed at packages/requirements/src/cli/versioning.ts (re-exported from ./types). An entry is { date: IsoDate; author: string; change: ChangeKind; reason: string; signature?: string }. The ChangeKind vocabulary is closed — the Style declares which change kinds are legal (e.g., Authored, Proposed, Reviewed, Approved, StatementEdited, PriorityRaised, Deprecated, Superseded, Withdrawn). The reason is free-text but mandatory (buildBumpHistoryEntry throws on an empty reason — the invariant is checked at construction time). The signature is optional and used by regulated-industry Styles where the audit trail needs a cryptographic witness. Why this matters: an auditor (internal QA, external regulator, a developer doing archaeology six months later) reading history[] sees when the change happened, who made it, what kind of change, and why. They do not have to reconstruct this from git blame, which knows about file bytes but not about semantic intent. The history entry is the narrative layer over the byte layer, and it is the only one that survives git rebase, history rewrites, and migrations between version-control systems. The discipline is: every structural change appends exactly one entry; no entry is ever edited or removed; the types refuse to let the history shrink.
There is a small epistemological point buried here worth lifting out. git blame tells you what text changed. history[] tells you what proposition changed. The two are not the same. A Requirement whose file was reformatted (whitespace, reordered fields) has the same contentHash before and after the reformat (because canonicalize() strips that noise), and therefore appends no history entry — correctly, since nothing semantically changed. A Requirement whose statement.response was edited has a new contentHash and must append an entry — correctly, since the proposition did change. The history is therefore a log of semantic events, not of textual ones. This distinction is what makes the history useful for audit: an auditor reading history[] sees only the events that mattered to the meaning of the Requirement, not the textual churn of refactorings and reformattings. An auditor reading git log on the file sees every event including the irrelevant ones, mixed together, and must manually filter for meaning. The history is pre-filtered by construction.
Discipline 8 — Bump $schemaVersion by date
Chapter 22e's argument: the wire format has its own clock, distinct from the library's code clock. The DSL's $schemaVersion is an ISO date, currently '2026-04-14', carried as a first-class field on every spec and checked by the JSON Schema validator at every on-disk read. When the format genuinely breaks — a new required field added (not optional; optional is additive), a field's type narrowed (a string becomes a branded RequirementId), a discriminated-union kind removed — the team does three things in strict order. First, coin a new date-stamp (e.g., '2026-07-01'). Second, ship a codemod under packages/requirements/src/cli/migrations/2026-07-01.ts that takes a '2026-04-14'-shaped spec and returns a '2026-07-01'-shaped one, deterministically and with no manual intervention. Third, run the codemod in CI with --dry-run for at least one release cycle, so every consumer sees the diff and can review; then, in the following release, make the migration required (old shape no longer parses). This is the discipline that lets the DSL evolve without breaking downstream repositories that have hundreds of Requirements at the old date-stamp. The codemod is the bridge; the date-stamp is the identifier of the bridge's two sides; the --dry-run phase is the grace period. Skip any of the three and you ship a silent breakage.
An important consequence of the date-based approach is that the DSL's backward-compatibility horizon is explicit. The package declares, for each shipping release, the list of schema dates it can still parse. A 0.5.0 release that supports 2026-04-14 and 2026-07-01 can load specs from either era; a 0.6.0 release that drops 2026-04-14 support can no longer load old specs (and the CHANGELOG says so, prominently). Consumers know exactly when they must migrate: before upgrading to the first release that drops their current schema date. This is considerably clearer than semver's implicit compatibility promises, which routinely break in subtle ways that take weeks to surface. The date-based contract is binary: either the release's SUPPORTED_SCHEMA_DATES array contains your spec's date, or it does not, and validateSpec tells you unambiguously. This clarity is the reason the date approach, despite looking unusual next to semver, is the right fit for a DSL whose persistence horizon is measured in months to years.
Discipline 9 — Deprecate, don't delete
A Requirement that no longer applies should not be deleted. Deletion removes the row from the registry; all @Satisfies edges pointing at its id become dangling; the audit trail referring to it refers to a ghost. Deprecation, by contrast, transitions the Requirement's status from Approved to Deprecated (or, in the IndustrialStyle lifecycle, to Retired with an explicit retirement date), appends a history entry { change: 'Deprecated', reason: '<why>' }, and — if there is a successor — sets the replacement Requirement's supersedes: <old-req-id> field. The compliance gate's orphan-Approved-Requirement check exempts the Deprecated state; the gate stops demanding a satisfier. But the registry still contains the Deprecated row, its audit trail is intact, its supersedes pointer lets a future reader walk forward through the chain of successors. In regulated-industry work (IEC 61508, 21 CFR Part 11) this is not style — it is mandatory. Even in a hobby project, the discipline pays off the first time someone asks "why did we ever have a Requirement about that?" and the answer can be produced from the registry, not reconstructed from memory.
There is also a quiet design win in the supersession chain. A Requirement deprecated today may itself be superseded by a later Requirement, which in turn may be superseded again. The chain REQ-A → REQ-B → REQ-C is walkable in both directions: given the current REQ-C, one can see REQ-C.supersedes === 'REQ-B', load REQ-B from the Deprecated rows, see REQ-B.supersedes === 'REQ-A', and end the walk at the origin. This chain is the history of the obligation itself, across renamings and restatings; it is not captured by git (which sees files, not obligations) and it is not captured by a spreadsheet. It is captured by the typed supersedes field, which the schema validates and the registry walks. When a regulator asks "what was the first version of this requirement, and what has it become?", the answer is a single query over the registry, not a research project.
Discipline 10 — Gate on compliance
The last discipline is the one that makes all the others self-enforcing: run compliance --strict as a precondition to git push, every time, on every branch. The gate's exit code is the AND of five conditions, gathered from chapter 13 and extended by chapter 15's drift detection and chapter 16's schema check: (1) no critical-priority AC uncovered (every Feature with priority: Critical has each abstract-method AC verified by at least one @Verifies edge); (2) no orphan Feature (every non-abstract Feature carries a non-empty @Satisfies list); (3) no orphan Approved Requirement (every Requirement in state Approved appears in at least one Feature's @Satisfies); (4) no version drift (detectVersionDrift returns empty — no req-evolved, no pin-stale, no unpinned-req for any Requirement that has evolved past v1); (5) schema-version match (every spec's $schemaVersion matches the schema files the tooling is currently shipping). Failure of any one is a merge blocker. The push wrapper — a ten-line bash script living in .githooks/ or the repository's Makefile — invokes compliance --strict after vitest run --coverage and before git push origin HEAD. Nothing reaches the remote until the gate is green. Per the repo's feedback_no_cloud_cicd convention, this gate runs on the developer's machine, not in GitHub Actions; that locality is what lets it run on every commit without waiting for a cloud round-trip. The gate is the hard floor under every other discipline — it is what makes "appended history entry" and "declared version pin" and "composed style" practically enforceable rather than merely recommended.
A subtle property of the gate, and the reason it is worth running locally rather than in a cloud CI, is that it is fast. The compliance scanner, on a repository with ~20 Requirements and ~25 Features, completes in under a second on a developer laptop (the AST walk is O(files), the registry build is O(edges), the gap detection is three linear passes). Running it as a pre-push hook adds well under a second to the push command — below the threshold where developers begin bypassing hooks with --no-verify because they are in a hurry. A cloud CI gate, by contrast, takes minutes (checkout, install, run) and creates the pressure of "wait for CI" that makes merge discipline lapse. Fast gates get run. Slow gates get bypassed. The whole thing works only because compliance --strict was designed to be sub-second: no reflection, no runtime instantiation, pure AST syntactic walk, frozen registry, deterministic output. The design choices in chapter 13 — port-driven, pure functions, no I/O in the core — pay off here as speed, which pays off as gate discipline, which pays off as the ten-discipline system actually converging.
The three axes as one system
The ten disciplines above do not sit at ten independent points on a checklist. They span three axes — the spatial axis of modularity, the lifecycle axis of a single Requirement's evolution, the schema axis of the DSL itself — and the axes interact. A change on one axis can, and often does, cascade onto the other two. The diagram below shows the interaction as a triaxial topology.
Two cascades are worth naming explicitly.
Lifecycle → modularity. When a Requirement authored in a project-local bundle (say, @frenchexdev/requirements-ssg) turns out to be generally applicable to every @frenchexdev/* project — for example, the No-JS baseline that originally lived in the SSG bundle but is really a platform-wide accessibility obligation — the lifecycle event "migrate to shared bundle" is a structural change that appends a history entry, bumps VersionInfo.version, and forces every Feature pinning that Requirement to update its pin (because the targetId has now moved from @frenchexdev/requirements-ssg's namespace into @frenchexdev/requirements-platform's). One lifecycle event, one bundle re-home, N pin updates. The compliance gate catches the whole cascade: until every Feature's pin is updated, detectVersionDrift returns non-empty, and compliance --strict refuses to let anything ship.
The mechanism that allows this cascade to be safe — rather than a frightening multi-file renaming exercise — is that the Requirement's id is stable across bundles. REQ-NO-JS does not become PLATFORM-REQ-NO-JS when it moves to the platform bundle. The id is the authority; the bundle is the hosting metadata. When the targetId on a pin remains 'REQ-NO-JS' before and after the move, the pin mechanically survives the bundle migration, and detectVersionDrift continues to find the Requirement (it lives in a different TypeScript namespace now, but the registry is a flat id-indexed map, not a namespace-indexed one). What changes is the import path in the Feature file — from '../requirements/no-javascript' to '@frenchexdev/requirements-platform' — and the build-time verification that the import resolves. This separation of identity (the id) from location (the bundle) is a deliberate decoupling that makes cross-bundle refactoring a tractable operation rather than a systemic risk.
Schema → modularity → lifecycle. A $schemaVersion bump from 2026-04-14 to 2026-07-01 — say, because the team decided owner: string must be a required field on every Requirement — triggers a migration in every bundle at once. The codemod rewrites every .ts file under requirements/requirements/ in every bundle, adding the new field with a default value. Each rewritten Requirement is a structural change on the lifecycle axis: VersionInfo.version increments, a history entry appends (change: 'SchemaMigrated', reason: 'bumped to 2026-07-01: required owner field'), and every Feature that pins any of the rewritten Requirements sees its pin go stale. The compliance gate, again, catches it: the codemod also updates the pins (a Feature-side migration shipped alongside), but if the codemod is incomplete — if a handful of Features escape the migration — the gate fails on pin-stale drift and the push aborts. The three axes turn out to compose into a single fault-detecting system: it is impossible for a change on one axis to silently leave the other two inconsistent, because each axis's invariant is checked by a gate that looks at all three.
The converse direction of this cascade — modularity → lifecycle — also occurs. When a bundle is carved out of another bundle (say, a new @frenchexdev/requirements-cli bundle is separated from @frenchexdev/requirements-platform), every Requirement moving into the new bundle sees a structural change (namespace move), bumps its version, and appends a history entry. The carving itself is a scripted operation (requirements split --from platform --to cli --filter cli-*), not a manual editing exercise; the script generates the new bundle's package.json, moves the Requirements, updates the import paths in every Feature that depends on them, regenerates the pins, and leaves a commit message that points at the split operation as the cause of the lifecycle bumps. The three axes compose under the carving operation exactly as they compose under the bump operation: the type system enforces coherence at compile time, the history records the narrative, the compliance gate refuses to let an inconsistent graph through. No axis is privileged; all three interact through the same invariants.
This is the synthesis. The disciplines are not a checklist. They are the operational shadow of a three-axis fault-detection system in which the type system, the append-only history, and the compliance gate together refuse to let the three axes drift out of coherence with each other.
Worked example — the full lifecycle of REQ-NO-JS
Abstractions, to land, need a specimen. The specimen is REQ-NO-JS, the No-JavaScript baseline Requirement for this very site, declared in requirements/requirements/no-javascript.ts. Its five phases below are the full end-to-end: birth, approval, satisfaction, evolution, bundle migration, schema bump. Every phase appends exactly one history entry. At no step does the audit trail break.
Step through it slowly.
Phase 1 — Birth. The author (the site's developer) reads WCAG 2.1 Success Criterion 4.1.1 ("Parsing — content rendered by a user agent must be parsable") and 1.3.1 ("Info and Relationships — information and structure conveyed through presentation must be programmatically determinable"), and decides the site needs an explicit Requirement making the baseline contract visible. The author creates requirements/requirements/no-javascript.ts, declaring NoJavaScriptRequirement with id = 'REQ-NO-JS', priority = Priority.Critical, kind = 'NonFunctional', status = 'Draft', a statement with pattern: 'ubiquitous' and response: 'serve every page's full textual content and internal navigation from a static HTML baseline, independently of JavaScript execution', a rationale with kind: 'regulatory-compliance' and evidence pointing at the W3C WCAG 2.1 standard, two fitCriteria (a demonstration scenario and a narrative description of the baseline self-sufficiency), a verificationMethod: 'Test', a source: { type: 'standard', org: 'W3C', id: 'WCAG 2.1 AA', section: '4.1.1 Parsing / 1.3.1 Info and Relationships' }, and a risk with level: 'High'. The initial VersionInfo is { version: 1, contentHash: sha256(canonical(REQ-NO-JS)), since: '2026-03-25' }. The initial history contains exactly one entry: { date: '2026-03-25', author: 'stephane', change: 'Authored', reason: 'transcribe WCAG 2.1 SC 4.1.1 and 1.3.1 obligations into a project Requirement' }.
Phase 2 — Approval. The site owner (in this narrative, also Stéphane, reviewing his own work in a stakeholder capacity) reads the Draft Requirement, agrees with the scope, and signs off. The status transitions from Draft to Approved. A history entry appends: { date: '2026-04-01', author: 'stephane', change: 'Reviewed', reason: 'site-owner sign-off — Draft to Approved, no substantive changes' }. The content hash does not change (status and history are not structural; canonicalize() in versioning.ts strips history before hashing, and the statement itself is unchanged). VersionInfo remains at v1 — approval is a lifecycle event, not a version bump. From this moment, the compliance gate will begin demanding a satisfier; until the Feature side is wired, compliance --strict fails on the orphan-approved-requirement gate. This is intentional — the gate failing is the signal that the next step (wiring satisfiers) must happen.
Phase 3 — Satisfaction. Three Features are authored, each carrying @Satisfies(NoJavaScriptRequirement, ...) on its class declaration. ProgressiveEnhancementBaselineFeature declares ACs for first-paint rendering without JS and for JS-less internal navigation. BuildPipelineFeature declares ACs for the static rendering pipeline that produces the baseline HTML. LinkValidationFeature declares ACs for the build-time validation that every internal link resolves to a real static page. Each Feature carries a versionPins table: [{ targetId: 'REQ-NO-JS', pinnedVersion: 1, pinnedHash: 'sha256:abc...' }] — the hash is the one computed at the end of Phase 1, pinned at the moment each Feature was wired. compliance --strict now passes all five gates. The No-JS baseline has a typed owner (the Requirement), three typed deliverables (the Features), and a typed validation policy (each Feature's @Satisfies edge + VersionPin).
Phase 4 — Evolution. Several weeks later, the site owner re-reads the Requirement and realises the statement is too narrow: it does not explicitly mention screen readers. A screen reader is a user agent that generally does execute JavaScript, but relies on the semantic accessibility tree, which is only populated reliably when the baseline HTML carries the semantic markup before hydration. The author amends the Requirement's statement.response to include "and accessibility trees (for screen readers and other assistive technologies) must be populated from the static baseline, before any JavaScript-driven hydration". The canonical form of NoJavaScriptRequirement now changes; contentHash changes; VersionInfo.version increments from 1 to 2, since updates to the new date, supersedes points at the old sha256:abc.... A history entry appends: { date: '2026-04-09', author: 'stephane', change: 'StatementEdited', reason: 'added explicit screen-reader / assistive-technology coverage' }. The next compliance --strict run fails with three pin-stale errors: each of the three Features has a pin pointing at v1/sha256:abc..., but the current Requirement is v2/sha256:def.... The three Features now have a choice: (a) decide the new obligation is structurally identical to what they already deliver, and simply bump their pin to v2/sha256:def... (no AC change needed); or (b) decide the new obligation demands additional or amended ACs, write those ACs, write the corresponding tests, then bump the pin. In this narrative, two Features (ProgEnh and LinkVal) take option (b) — they amend their AC lists to include explicit screen-reader coverage, write the tests, then bump pins — and one Feature (Build) takes option (a), because the build pipeline is downstream of whatever ACs the other two declare and does not itself change.
Phase 5 — Bundle migration. Some months later, as the @frenchexdev/* monorepo matures, it becomes clear that REQ-NO-JS is not an SSG-specific Requirement. It applies to every @frenchexdev/* project that ships HTML — including the upcoming TUI documentation generator, the component library's demo site, every downstream application. The author migrates REQ-NO-JS out of the repo-root requirements/requirements/ and into @frenchexdev/requirements-platform's shared obligations list. A history entry appends: { change: 'Rehomed', reason: 'generalised to platform-wide accessibility obligation' }. The three satisfying Features update their import paths from '../requirements/no-javascript' to '@frenchexdev/requirements-platform'. The targetId on each pin remains 'REQ-NO-JS' (the id is stable across bundles; the bundle namespace is separate metadata). The compliance tool recognises the cross-bundle edge as valid because the platform bundle is in the shared-kernel whitelist; without that whitelist, cross-bundle @Satisfies would itself be a gate failure. This is chapter 14's modularity mechanism composing with chapter 15's lifecycle mechanism: the Requirement's home moved, the Features' imports moved, the pins survived, the compliance gate stayed green after the coordinated edit.
Phase 6 — Schema bump. Later still, the DSL itself bumps its $schemaVersion from '2026-04-14' to '2026-07-01'. The new schema requires a new owner: string field on every Requirement (so audit reports can say who owns each rule without spelunking through history entries). The team runs npx requirements migrate --from 2026-04-14 --to 2026-07-01 --dry-run first; the codemod reports that REQ-NO-JS (and every other Requirement in every bundle) needs owner: 'stephane' (default derived from the most recent history-entry author). After review, the team runs the migration without --dry-run. Every Requirement gains its owner field. Every Requirement gains a history entry: { change: 'SchemaMigrated', reason: '$schemaVersion 2026-04-14 -> 2026-07-01: added required owner field' }. Every Feature's JSON binding is regenerated under the new schema. VersionInfo increments on every migrated Requirement (a schema-driven bump is still a structural change). The pin-sync subcommand bumps every pin in lockstep. compliance --strict passes. The six phases — birth, approval, satisfaction, evolution, bundle migration, schema bump — have all run to completion, and the audit trail in REQ-NO-JS's history[] carries six entries, one per phase, each legible, each typed, each explaining what changed and why. No commit-message spelunking required.
The point of the worked example is not that every Requirement will go through all six phases. Most will not. The point is that the mechanism is designed so each phase is composable with the others, each phase leaves the audit trail legible, and each phase is self-validating through the compliance gate. This is what end-to-end discipline means in practice: the six phases are not enforced by individual discipline; they are enforced by the types + the gate, and the discipline is what you do to stay on the green path.
When this discipline is overkill
Not every project needs all ten disciplines. Scale matters — quite a lot — and pretending otherwise produces the characteristic failure mode of this kind of writing, which is a reader concluding that the advice is impossible to adopt and giving up. The matrix below is the scale-dependency honestly drawn.
A five-Requirement hobby project can skip VersionPin entirely — the Requirements barely evolve, the satisfier graph is small enough to hold in one's head, and the overhead of writing a pin for every Feature buys nothing. That project should still write Requirements first (discipline 1), should still classify their origin (discipline 2), and should still run compliance --strict (discipline 10) — those three are free, nearly so — but it does not need the full lifecycle machinery.
A fifty-Requirement B2B SaaS has different needs. The Requirement graph is now too big to hold mentally; multiple developers are writing Features against Requirements they did not author; drift between what a Feature was built for and what the Requirement now says is a real risk. At this scale, disciplines 3 (composeStyle), 4 (@Refines), 5 (@Satisfies not extends) become load-bearing. Disciplines 6 (VersionPin), 7 (history[]) and 9 (deprecate-don't-delete) are probably unnecessary yet — the project's Requirements do not evolve often enough for drift to be a concrete problem — but discipline 8 ($schemaVersion migration protocol) starts to matter if the project is consuming @frenchexdev/requirements from npm and tracking its schema bumps.
A five-hundred-Requirement regulated project — medical devices (IEC 62304), rail (EN 50128), finance (Basel III, SOX, MiFID), energy (IEC 61508), aviation (DO-178C) — needs every discipline in place, plus the optional signature? field on HistoryEntry populated with cryptographic witnesses so regulatory sign-offs are non-repudiable. The IndustrialStyle (the 13-state lifecycle, SIL 1–4) was designed for exactly this. At this scale, skipping any discipline is not a pragmatic shortcut; it is an audit finding waiting to happen.
The point is that the ten disciplines are a spectrum, not a binary. Pick the subset that matches your scale, adopt it consistently, and accept that as your project grows, the subset will need to grow too. Do not adopt all ten for a weekend project. Do not skip any for a regulated one.
Closing — the compiler as ally, not as gate
The whole point of typing requirements — chapter 00's opening thesis, restated — was to make the TypeScript compiler carry the specification load. The series closes with the same claim, now earned rather than asserted.
Every one of the ten disciplines above has a type behind it that makes accidental violations impossible. @Satisfies(Req) — the decorator's generic constraint narrows the argument to a new (...args: never) => Requirement<S> constructor type; an arbitrary string or a Feature class fails compilation. @Refines(ParentReq) — the same constraint narrows the parent to Requirement<S>; you cannot @Refines a Feature. VersionPin.targetId: RequirementId — not a raw string; a branded type whose smart constructor validates the shape at runtime and whose brand prevents accidental assignment of a plain string in code that has access to the brand. HistoryEntry.change: ChangeKind — a closed discriminated union; an arbitrary string fails compilation. $schemaVersion: IsoDate — a branded string whose parser accepts only /^\d{4}-\d{2}-\d{2}$/; a random string fails at spec-load time. statement.pattern: keyof StatementPatterns<S> — where S is the project's Style, the pattern is narrowed to the Style's statementPatterns map at compile time.
Every discipline has a type. The types make the accidental case impossible. The types do not make the deliberate violation case impossible — a developer determined to bypass the system can always write as any, or forge a history entry by editing the JSON on disk, or skip the compliance --strict gate by running git push --no-verify. That is what the compliance gate is for. The compiler is the fence that stops the accidental violation (the typo, the misremembered API, the copy-paste gone wrong); the gate (compliance --strict invoked as a push precondition) is the net that catches the deliberate one.
The relationship between the two is complementary, not redundant. The compiler runs continuously, on every file save, and catches the 95% of violations that would be accidental. The gate runs once per push, and catches the remaining 5% that bypass the compiler. Together they form a system in which it is easier to do the right thing than the wrong thing — which is the only durable criterion for a discipline that hopes to survive past the author's initial enthusiasm. If the right thing requires constant vigilance, the discipline will lapse. If the right thing is what the tools produce by default, the discipline will stick.
The ten disciplines in this chapter are not rules you must remember. They are the behaviour the tools produce. When the tools are in place, and the gate is wired, the disciplines are the shape of what you do without thinking about it. That is the end-to-end state this series has been aiming at.
Where the series goes next
This chapter closes the dog-food arc of the series. The three chapters preceding it (14, 15, 16) introduced mechanisms; this chapter composed them into a discipline; the arc is structurally complete. What comes next is extraction: turning the mechanisms from propositions inside this blog into real shipping code inside the @frenchexdev/requirements package.
Three concrete follow-ups are already scoped in the monorepo plan file. First, composeStyle() needs to become a first-class export rather than a narrative device — the current DefaultStyle is hand-extensible, but a formal composition API with a mergeability invariant (additive-only on requirementKinds, statusesOf, fitCriterionKinds) is a future chapter. Second, requirements migrate needs to become a real CLI subcommand, with a migration registry under packages/requirements/src/cli/migrations/ keyed by { from: IsoDate; to: IsoDate } and an AST-based codemod for each breaking schema bump. Third, the bundle carve itself — turning today's monorepo-internal @frenchexdev/requirements-platform and @frenchexdev/requirements-ssg drafts into published npm packages with a proper dependency graph, semver discipline, and CHANGELOG discipline — is a phase-by-phase migration the plan lays out across five concrete PRs.
Those three follow-ups are the next future chapters. This chapter is the one that closes the synthesis of what we already have. The mechanism is here. The discipline is here. The gate is green. Everything else is delivery.
Cross-references
The three chapters this synthesis composes:
- Chapter 22c — Modularity and Sub-Packages — the bundle split, the shared kernel, and
composeStyle(DefaultStyle, overrides). - Chapter 22d — Lifecycle Discipline —
status,history[],@Refines,VersionInfo,VersionPin,detectVersionDrift, and the four ways to evolve a Requirement. - Chapter 22e — Schema Evolution — date-based
$schemaVersion, additive-only Style evolution, codemod migrations, library semver vs schema date.
Earlier chapters referenced throughout:
- Chapter 00 — Named but Not Modelled — the opening thesis that the compiler should carry the specification load.
- Chapter 13 — Quality Gates and Compliance — the
compliance --strictgate and its three (now five) AND-ed conditions.
Real files cited in this chapter:
packages/requirements/src/base.ts—Requirement<S>,Feature,Priority,ACResult.packages/requirements/src/cli/versioning.ts—VersionInfo,VersionPin,VersionDrift,HistoryEntry,computeVersionInfo,detectVersionDrift,applyVersionBump.packages/requirements/src/cli/scenario/project-lifecycle-fsm.ts— the eight-state FSM with its declarative transition table.requirements/requirements/no-javascript.ts— the worked example's subject.requirements/features/progressive-enhancement-baseline.ts— satisfier 1.requirements/features/build-pipeline.ts— satisfier 2.requirements/features/link-validation.ts— satisfier 3.