From 31d41cd214622792ccb72df0160afe4ed9495ace Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 18:30:29 +0200 Subject: [PATCH 1/6] Write requirement anchors without spaces inside the braces markdownlint's MD051 recognises a custom heading anchor only when the braces contain no surrounding whitespace. With the spaced form the heading keeps its slugified anchor, so same-page references such as [FR1](#fr1) -- the form Spec-Driven-Development.md tells authors to use -- are reported as broken link fragments, and a spec written by following that page fails the linter the ecosystem runs in CI. Convert 16 occurrences: 2 in the Spec-Driven-Development.md prose that introduces the form, and 14 in deployment/spec.md. Both forms render identically under Python-Markdown's attr_list, which this site enables, so the published output is unchanged. Test-DocumentationLink.ps1 matches the id with \s* around the brace contents, so it resolves either form. Two pages this commit used to touch have moved on: #158 retired process-psmodule/spec.md with the rest of that capability's pages, and #160 moved the specification templates out to Spec-Driven-Development-Templates.md. The anchors that arrived with #160, there and in its new worked example, are converted in the commit that follows this one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/docs/Capabilities/deployment/spec.md | 28 +++++++++---------- .../Spec-Driven-Development.md | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/docs/Capabilities/deployment/spec.md b/src/docs/Capabilities/deployment/spec.md index 12afc4f..acb5a65 100644 --- a/src/docs/Capabilities/deployment/spec.md +++ b/src/docs/Capabilities/deployment/spec.md @@ -79,85 +79,85 @@ Out of scope: Requirements use [BCP 14](https://www.rfc-editor.org/info/bcp14) keywords. -### FR1 — A change is expressed as code and proposed for review { #fr1 } +### FR1 — A change is expressed as code and proposed for review {#fr1} Every change to managed resources MUST be expressed as code and proposed for review before it is deployed. A change that reaches resources without review MUST be possible only through the emergency path ([FR8](#fr8)). -### FR2 — The effect of a change is computed and shown for review { #fr2 } +### FR2 — The effect of a change is computed and shown for review {#fr2} For each environment the change will pass through, the process MUST compute the change's **effect on the managed resources** — what it creates, updates, and destroys — and make every one of these effects available during review. Computing the effect successfully MUST be a condition of acceptance. -### FR3 — Approval covers the code change together with its effect { #fr3 } +### FR3 — Approval covers the code change together with its effect {#fr3} Acceptance MUST approve the code change **and** its computed effect on every environment in the promotion path, as one decision. Approving the code alone, without its effect, MUST NOT satisfy this requirement. -### FR4 — The approved effect is exactly what is deployed { #fr4 } +### FR4 — The approved effect is exactly what is deployed {#fr4} The deployment to each environment MUST carry out the exact effect that was approved for that environment. The process MUST NOT re-derive the effect between approval and deployment in a way that could act on resources no one approved. -### FR5 — Deployment fails closed when the approved effect is no longer valid { #fr5 } +### FR5 — Deployment fails closed when the approved effect is no longer valid {#fr5} If the approved effect for an environment is no longer valid for that environment's current state at deployment time, the deployment MUST stop without carrying out a divergent effect. Resolution MUST be to recompute the effect and have it approved again — never to force the invalid one. -### FR6 — Changes against the same resources are serialised { #fr6 } +### FR6 — Changes against the same resources are serialised {#fr6} At most one change MUST be deployed against a given set of resources at a time. Concurrent deployments against the same resources MUST be prevented, and a deployment in progress MUST NOT be interrupted partway. -### FR7 — The process runs its automated tests { #fr7 } +### FR7 — The process runs its automated tests {#fr7} The process MUST run its defined automated tests as part of carrying out a change, and a failing required test MUST stop the promotion before it reaches the next environment. -### FR8 — An audited emergency path exists { #fr8 } +### FR8 — An audited emergency path exists {#fr8} An emergency path MUST be able to deploy a change without the standard review. It MUST require a recorded reason and an incident reference, MUST be restricted to authorised people, MUST still compute and show the effect before it deploys, and MUST open a follow-up that brings the change back through the standard path. -### FR9 — Every deployment documents the actions it took { #fr9 } +### FR9 — Every deployment documents the actions it took {#fr9} Every deployment, standard or emergency, MUST record the actions it performed — who approved, who deployed, when, against which environment, the effect applied, and the outcome of its tests — retained as an immutable record. -### FR10 — Drift is detected and surfaced { #fr10 } +### FR10 — Drift is detected and surfaced {#fr10} Divergence between the real resources and the recorded state SHOULD be detected on an ongoing basis and surfaced for action. -### NFR1 — No standing secrets { #nfr1 } +### NFR1 — No standing secrets {#nfr1} Access to managed resources MUST use short-lived, federated identity. Zero long-lived credentials or static secrets exist in the repository or its automation. -### NFR2 — Least privilege separated by phase { #nfr2 } +### NFR2 — Least privilege separated by phase {#nfr2} Computing a change's effect MUST use read-only access; deploying it MUST use write access scoped to the target environment; the two MUST be distinct. -### NFR3 — Per-environment identity boundary { #nfr3 } +### NFR3 — Per-environment identity boundary {#nfr3} Each environment MUST have its own access identity, so an effect approved for one environment cannot be deployed to another. Zero cross-environment deployments. -### NFR4 — Complete auditability { #nfr4 } +### NFR4 — Complete auditability {#nfr4} 100% of deployments, including emergency ones, MUST produce a retained, attributable record ([FR9](#fr9)). diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index 60c1ef0..c843f6e 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -106,7 +106,7 @@ Requirements are testable statements of what must be true — never how it is bu **Functional** requirements describe what the capability does, as observable behavior. **Non-functional** requirements are the quality attributes the capability must hold — performance, security, reliability, availability, compliance, observability, and cost — each stated as a measurable condition with a threshold; a non-functional requirement without a number is an opinion. For platform and infrastructure work these are often the point of the change rather than an afterthought — latency, redaction, retention, and blast radius decide whether the thing is fit to run. -Give each requirement its own heading with a stable, explicit anchor — `### FR1 — { #fr1 }` for functional, `### NFR1 — { #nfr1 }` for non-functional. The anchor is the identifier alone, so the heading can be reworded without breaking a single reference. Identifiers are **append-only**: assign the next unused number, never renumber, and never reuse — a removed requirement simply disappears, and git holds the history. +Give each requirement its own heading with a stable, explicit anchor — `### FR1 — {#fr1}` for functional, `### NFR1 — {#nfr1}` for non-functional. The anchor is the identifier alone, so the heading can be reworded without breaking a single reference. Identifiers are **append-only**: assign the next unused number, never renumber, and never reuse — a removed requirement simply disappears, and git holds the history. Numbering is scoped **per page**. Every spec page — a core spec and each of its [feature addenda](#core-and-feature-addenda) — starts at `FR1` and `NFR1`. Identity is the page plus the anchor, so a requirement is referenced as `[FR1](#fr1)` on the same page and `[FR1](spec.md#fr1)` across pages; a feature reference has the form `features/.md#fr2`. Because identity includes the page, moving a set of requirements onto a new feature page never forces a renumber. From 7b21fee47235238f6a9a7b391bc1584e0fa9e3a6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 18:51:15 +0200 Subject: [PATCH 2/6] Enable MD051 again now that the anchors it could not read are gone The rule was disabled because the documentation wrote attr_list anchors with spaces inside the braces, a form markdownlint cannot resolve, and Test-DocumentationLink.ps1 covered the gap instead. The anchors are now unspaced, so the stated reason no longer holds. MD051 is a genuine correctness check: it catches references to headings that do not exist, which is the failure mode append-only requirement identifiers exist to prevent. Removing the disable and running markdownlint-cli2 v0.23.2 with this configuration over all 123 tracked Markdown files reports 0 issues, so the check is kept on rather than left disabled against a justification that no longer applies. Test-DocumentationLink.ps1 still runs and still validates fragments markdownlint does not see -- MD051 checks same-file fragments only, so cross-file references such as spec.md#fr1 continue to depend on it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/linters/.markdown-lint.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml index a5eb09c..4c0f98c 100644 --- a/.github/linters/.markdown-lint.yml +++ b/.github/linters/.markdown-lint.yml @@ -21,7 +21,6 @@ MD029: false # Ordered list item prefix MD033: false # Allow inline HTML MD036: false # Emphasis used instead of a heading MD041: false # First line in file should be a top level heading, PULL_REQUEST_TEMPLATE.md is an exception -MD051: false # Link fragments: docs use python-markdown attr_list anchors ({ #fr1 }) that markdownlint can't resolve; Test-DocumentationLink.ps1 validates fragments instead MD060: false # Table column style — content mixes compact and aligned tables ################# From a2c9a0d8bc745989969741a1045af4a0c2a5b0ab Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 18:53:34 +0200 Subject: [PATCH 3/6] Show the unspaced anchor form in the link checker's own help The comment-based help and the parsing comment illustrated an explicit attr_list id as '## Heading { #id }', the spaced form the documentation no longer uses. The script's help is where a reader learns the anchor form while working on the checker itself, so leaving it kept teaching the form being removed. Only the examples change. The matching expression is untouched and still resolves every variant it did before, including the colon-prefixed '{: #id ... }' the comment continues to name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/Test-DocumentationLink.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/Test-DocumentationLink.ps1 b/.github/scripts/Test-DocumentationLink.ps1 index 6e99417..6767f1a 100644 --- a/.github/scripts/Test-DocumentationLink.ps1 +++ b/.github/scripts/Test-DocumentationLink.ps1 @@ -14,7 +14,7 @@ - A heading anchor ('target.md#section', or a same-page '#section') must match a heading in the target file. Slugs are computed the same way the site's Markdown processor does, including the '_1', '_2' suffixes for duplicate - headings; an explicit attr_list id ('## Heading { #id }') is recognised as + headings; an explicit attr_list id ('## Heading {#id}') is recognised as the heading's anchor. External links (http, https, mailto, tel), absolute paths, links inside fenced @@ -80,7 +80,7 @@ function Get-HeadingSlug { .DESCRIPTION Return each heading's anchor, matching the duplicate-slug suffixing ('_1', '_2', ...) the Markdown processor applies to repeated headings. A - heading may also carry an explicit attr_list id ('## Heading { #id }'), + heading may also carry an explicit attr_list id ('## Heading {#id}'), which the site renderer uses as the anchor verbatim, overriding the text slug; those are recognised so links to '#id' validate. Fenced code blocks are skipped. @@ -106,7 +106,7 @@ function Get-HeadingSlug { if ($inFence) { continue } if ($line -match '^#{1,6}\s+(.+?)\s*$') { $text = $matches[1] - # An explicit attr_list id ('{ #id }' or '{: #id ... }') wins over + # An explicit attr_list id ('{#id}' or '{: #id ... }') wins over # the text slug, exactly as python-markdown's attr_list assigns it. if ($text -match '\{\s*:?\s*#([-\w]+)[^}]*\}\s*$') { $slugs.Add($matches[1]) From f5fa8029d3fac1c14f061e5f351f3a2ccf4bb370 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 19:04:25 +0200 Subject: [PATCH 4/6] Record MD051 and the anchor form in the Markdown standard The page claims the shared configuration is the source of truth and then enumerates the rules, but MD051 appeared in neither table. It had been disabled in .github/linters/.markdown-lint.yml and never written down, so the standard was already out of step with the configuration; enabling the rule in this branch made the omission a live one, because a link to a heading that does not exist now fails the build for every page rather than only for specifications. Add MD051 to the enforced rules, state the anchor syntax once in the style section, and record the limitation that matters: MD051 validates same-file fragments only, so a clean lint run is not evidence that a cross-file fragment resolves. The anchor syntax belongs here rather than only in Spec-Driven-Development.md. That page owns the FR/NFR identifiers; the brace form applies to any heading on any page, and keeping it in a page about specifications is how the spaced form spread in the first place. The two pages cross-link instead of restating each other. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/docs/Coding-Standards/Markdown.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/docs/Coding-Standards/Markdown.md b/src/docs/Coding-Standards/Markdown.md index bb12aad..846df98 100644 --- a/src/docs/Coding-Standards/Markdown.md +++ b/src/docs/Coding-Standards/Markdown.md @@ -31,9 +31,12 @@ These overrides and defaults are active, so author to them: | MD026 | Headings do not end with trailing punctuation (`. , ; : !`). | | MD046 | Code blocks are **fenced** (` ``` `), never indented. | | MD048 | Code fences use **backticks**, not tildes. | +| MD051 | Link fragments resolve — a `#anchor` must match a real heading. A heading's custom anchor is written `{#id}`, with no spaces inside the braces. | Beyond these the linter runs the default ruleset, so also honour its common defaults: headings tagged with a language on every code fence, no trailing whitespace, and a single trailing newline. +MD051 validates **same-file** fragments only. A cross-file fragment such as `spec.md#fr1` is never examined by the linter, so a clean lint run is not evidence that cross-file anchors resolve — a repository that relies on them needs its own link checker as well (in this repository, `.github/scripts/Test-DocumentationLink.ps1`). + ## Relaxed on purpose These rules are disabled or widened so they do not flag valid documentation — do not work around them: @@ -54,6 +57,7 @@ These rules are disabled or widened so they do not flag valid documentation — - **Surround headings, lists, and fenced blocks with a blank line** for readability, even though the linter no longer enforces it. - **Prefer relative links** within a repository; use the canonical published URL for cross-repository references. - **Give a repeated or long link a reference-style definition** (`[text][ref]`, with `[ref]: url` listed below) so the prose stays readable and one edit updates every use. +- **Write a heading's custom anchor as `{#id}`** when it must stay linkable under a stable identifier. [`attr_list`](https://python-markdown.github.io/extensions/attr_list/) also accepts `{ #id }` and `{: #id }` and renders all three identically, but markdownlint reads only the unspaced form — the others leave the heading on its slugified anchor, so same-page references to the identifier are reported as broken fragments and cross-file ones break silently. [Spec-Driven Development](../Ways-of-Working/Spec-Driven-Development.md#requirements) owns the FR/NFR identifiers themselves; the syntax applies to any page. - **Tag every code fence with a language** (` ```bash `, ` ```yaml `) so it is highlighted and converts cleanly when published. - **Wrap code, commands, filenames, and identifiers in backticks** rather than bold or italic, so they read as code and do not lean on the emphasis the linter now allows freely. - **Give every image descriptive alt text** — `![what the image shows](diagram.png)` — so it serves screen readers and still says something when the image fails to load; use a relative path for images kept in the repository. From 1a7c25ce7cba26e69f5548cfd8fe90df54fb2ed6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 18:31:22 +0200 Subject: [PATCH 5/6] Convert the anchors that arrived with the templates split #160 moved the specification skeletons out to Spec-Driven-Development-Templates.md and added a worked requirement example to Spec-Driven-Development.md. Both were written with the spaced brace form, which was still what the standard taught at the time and which nothing in CI would have caught, since MD051 was disabled. These six anchors sit inside fenced code blocks, so neither markdownlint nor Test-DocumentationLink.ps1 examines them and no check was failing. They matter because they are the text an author copies: the skeleton is the vector that carried the broken form into PSModule/Markdown#33 in the first place. A template that disagrees with the rule the same standard now enforces is the defect this branch exists to remove. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Spec-Driven-Development-Templates.md | 10 +++++----- src/docs/Ways-of-Working/Spec-Driven-Development.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development-Templates.md b/src/docs/Ways-of-Working/Spec-Driven-Development-Templates.md index a6b2791..e4f8875 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development-Templates.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development-Templates.md @@ -51,7 +51,7 @@ description: ## Functional requirements -### FR1 — { #fr1 } +### FR1 — {#fr1} #### Behavioral scenarios @@ -62,11 +62,11 @@ Scenario: Then ``` -### FR2 — <...> { #fr2 } +### FR2 — <...> {#fr2} ## Non-functional requirements -### NFR1 — { #nfr1 } +### NFR1 — {#nfr1} #### Behavioral scenarios @@ -138,7 +138,7 @@ description: ## Functional requirements -### FR1 — { #fr1 } +### FR1 — {#fr1} #### Behavioral scenarios @@ -151,7 +151,7 @@ Scenario: <...> ## Non-functional requirements -### NFR1 — { #nfr1 } +### NFR1 — {#nfr1} ```` ## Design diff --git a/src/docs/Ways-of-Working/Spec-Driven-Development.md b/src/docs/Ways-of-Working/Spec-Driven-Development.md index c843f6e..a4a50d6 100644 --- a/src/docs/Ways-of-Working/Spec-Driven-Development.md +++ b/src/docs/Ways-of-Working/Spec-Driven-Development.md @@ -115,7 +115,7 @@ Numbering is scoped **per page**. Every spec page — a core spec and each of it Each requirement owns a `#### Behavioral scenarios` subsection immediately beneath it, holding one or two Given / When / Then scenarios that make that one requirement concrete in place. These are the acceptance criteria for that requirement, stated once, where the requirement is stated. ````markdown -### FR1 — A request MUST succeed while any healthy upstream provider remains { #fr1 } +### FR1 — A request MUST succeed while any healthy upstream provider remains {#fr1} #### Behavioral scenarios From 9a66332cf5a202679449ec82a4f249856c900708 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 18:43:59 +0200 Subject: [PATCH 6/6] Say that the anchor syntax comes from a flavor, not from Markdown The page opens by saying documentation is authored in GitHub Flavored Markdown, and then, since the previous commit, requires custom heading anchors written as {#id}. Those two statements disagree, and the reason is broader than GFM: no Markdown specification defines heading attributes at all. Not Gruber's original syntax, not CommonMark, not GFM. The construct belongs to individual flavors -- PHP Markdown Extra and Python-Markdown, kramdown's inline attribute lists, Pandoc -- and this site gets it from Python-Markdown's attr_list extension. Verified against the renderers rather than the documentation. GitHub's own POST /markdown with mode gfm returns

FR1 {#fr1}

, and the same literal output for { #fr1 } and {: #fr1 }. Python-Markdown 3.10.2 with attr_list, which is what Zensical publishes with, turns all three into

. Name that where the rule is stated, so an author who sees braces in the repository file view knows it is expected rather than a mistake, and knows the rule is a property of this toolchain rather than something a specification settles. {#id} remains the form to write: it is the only one markdownlint parses, which is an intersection of implementations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/docs/Coding-Standards/Markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/Coding-Standards/Markdown.md b/src/docs/Coding-Standards/Markdown.md index 846df98..6bdff8e 100644 --- a/src/docs/Coding-Standards/Markdown.md +++ b/src/docs/Coding-Standards/Markdown.md @@ -57,7 +57,7 @@ These rules are disabled or widened so they do not flag valid documentation — - **Surround headings, lists, and fenced blocks with a blank line** for readability, even though the linter no longer enforces it. - **Prefer relative links** within a repository; use the canonical published URL for cross-repository references. - **Give a repeated or long link a reference-style definition** (`[text][ref]`, with `[ref]: url` listed below) so the prose stays readable and one edit updates every use. -- **Write a heading's custom anchor as `{#id}`** when it must stay linkable under a stable identifier. [`attr_list`](https://python-markdown.github.io/extensions/attr_list/) also accepts `{ #id }` and `{: #id }` and renders all three identically, but markdownlint reads only the unspaced form — the others leave the heading on its slugified anchor, so same-page references to the identifier are reported as broken fragments and cross-file ones break silently. [Spec-Driven Development](../Ways-of-Working/Spec-Driven-Development.md#requirements) owns the FR/NFR identifiers themselves; the syntax applies to any page. +- **Write a heading's custom anchor as `{#id}`** when it must stay linkable under a stable identifier. No Markdown specification defines heading attributes — not [the original syntax](https://daringfireball.net/projects/markdown/syntax), not [CommonMark](https://spec.commonmark.org/), not [GFM](https://github.github.com/gfm/) — so the form belongs to a flavor rather than to Markdown itself: [`attr_list`](https://python-markdown.github.io/extensions/attr_list/), which the site enables and GitHub does not, which is why the braces become an `id` on the published page but show as literal text in a repository file view. `attr_list` accepts `{ #id }` and `{: #id }` as well, but markdownlint reads only the unspaced form — the others leave the heading on its slugified anchor, so same-page references to the identifier are reported as broken fragments and cross-file ones break silently. [Spec-Driven Development](../Ways-of-Working/Spec-Driven-Development.md#requirements) owns the FR/NFR identifiers themselves; the syntax applies to any page. - **Tag every code fence with a language** (` ```bash `, ` ```yaml `) so it is highlighted and converts cleanly when published. - **Wrap code, commands, filenames, and identifiers in backticks** rather than bold or italic, so they read as code and do not lean on the emphasis the linter now allows freely. - **Give every image descriptive alt text** — `![what the image shows](diagram.png)` — so it serves screen readers and still says something when the image fails to load; use a relative path for images kept in the repository.