Skip to content

docs(sds): stable rule identifiers -- 145 requirements become citable, and a guard that keeps them stable - #277

Merged
wshallwshall merged 9 commits into
mainfrom
claude/handoff-sds-a3bc90
Aug 7, 2026
Merged

docs(sds): stable rule identifiers -- 145 requirements become citable, and a guard that keeps them stable#277
wshallwshall merged 9 commits into
mainfrom
claude/handoff-sds-a3bc90

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

docs/Secure_Development_Standards.md gains stable rule identifiers. 8 commits, 5 files, +818/-99.

145 requirements now carry an SDS-<section>.<n> identifier, an RFC 2119 keyword, and named
evidence
. Appendix A.6 no longer cites any requirement by position.

The defect: rules were citable only by POSITION

Nothing kept a citation pointing at the same rule. A section renumber silently repointed every
reference to it -- the same pointer rot class the ledger has been shedding all day, but worse: a
backlog pointer that rots resolves to the wrong file and is at least findable, while a positional
citation resolves to a different requirement and still looks correct.

SECTION NUMBERS ARE UNCHANGED. Identifiers were added alongside rather than replacing the
numbering, so every existing citation still resolves. The fix adds a stable handle without
invalidating the unstable one -- no migration, no coordinated update of referencing docs.

New guard

tests/test_sds_rule_ids_are_stable.py, wired into the doc-guards CI step at both module lists --
the second list being exactly the kind of place a guard silently fails to run.

One change LOOKS like a regression and is not

tests/test_dast_claims.py pinned the A.6 independent-review row verbatim, including its section
numbers
, so repointing that row broke the pin.

That is the pin working -- the anchor moved. It now names SDS-6.4.3, with a comment recording
why. A pin that breaks when the thing it pins moves is doing its job; the failure mode to fear is the
pin that keeps passing after the anchor drifts.

Verification

full suite, BOTH roots : 10992 passed, 847 skipped, exit 0
ruff + mypy strict     : clean
merges clean onto      : 7ecff8ae

⚠️ pytest's exit code was captured to a FILE, not read through a pipe -- $? after a pipe reports
the last element of the pipeline, not pytest. That distinction has produced false greens here before.

Conflict-free against origin/main and against both in-flight merge trains (#274, #275), checked with
git merge-tree --write-tree. Adds zero docs/BACKLOG.md headings, so no allocation entitlement
is involved.

Full rationale, the two defects fixed in passing, and two out-of-scope findings already spawned as
their own sessions: .git/mefor-coord/handoff-sds-a3bc90.md (not published -- it names worktrees).

… citation pointing at one

A section number is a position, not a name. This document has already been renumbered once -- Version
history records the shift of sections 5 through 9 down by one when Spec-Driven Development was
inserted -- and the citations written against the old scheme did not error, did not render
differently, and were not noticed. They simply began naming different requirements. Ten of them in the
maintainer's private security documents still resolve to the wrong section today: four name the
release gate by a number that now belongs to "Clarify and analyze".

This commit adds the convention and the gate that enforces it, BEFORE any rule carries an identifier.
That order is deliberate: a guard added after the rules cannot catch what drifted while it was absent.

  - "How to read the rules" states the SDS-<section>.<n> grammar, the RFC 2119 keywords, the meaning
    of the Evidence column, and the append-never-insert / reword-vs-retire rule. It is an UNNUMBERED
    section, so it renumbers nothing.
  - "Retired rules" holds the tombstones, so a citation that outlives its rule lands on a record of
    what happened rather than on whatever requirement later took the number.
  - tests/test_sds_rule_ids_are_stable.py makes that text mechanical.

The guard is derivable from the working tree alone. It cannot use history: the required test leg
checks out shallow, so an identifier's past is unreadable where it runs.

Every check was fired at input built to break it before its green was credited -- nine mutations
applied to the real document, each confirmed red with a repository-relative message, the file restored
byte-identically after each. One mutation initially reported a check as blind; the mutation was wrong
rather than the check, having replaced the asserted heading with a string that still contained it.

Failure output is repository-relative on purpose. An absolute path on a developer machine carries a
worktree slug, and a bare slug is a shape the forbidden-content guard does not catch before it reaches
this public repository (BACKLOG #1083).

Wired into the doc-guards CI step at BOTH of its module lists. Missing either one leaves the guard
silent on docs-only pull requests, which is the exact change shape it exists to catch.

What it deliberately does not catch is recorded in the module docstring: a reworded rule whose meaning
changed, any citation in the private vault (invisible where CI runs), and the published PDF and its
website mirror, which live in a repository with no CI and are already divergent.
…could be cited or checked

The SSDF section carried the mass of this standard's requirements as bulleted prose. Three
consequences, all of which this commit removes.

A citation had nowhere to land. "PW.5" names eight sub-bullets carrying twenty-seven separate
obligations between them, so citing PW.5 in a review or a deviation named a paragraph, not a
requirement. Identifiers now name one obligation each.

Nothing stated force. The whole of section 4 was written in the bare declarative present -- "Every
change is peer-reviewed", "Releases are versioned" -- which reads as description rather than
obligation and leaves a reader to guess whether departing from it is a deviation or a judgment call.
Every rule now carries a capitalised RFC 2119 keyword. This is a mood conversion, not a change of
content: no obligation was added, removed, weakened or strengthened.

Nothing said what would settle it. Each rule now names the evidence a reviewer looks at, which is what
makes it pass-or-fail rather than aspirational. Several of those evidence cells are deliberately
uncomfortable -- "a clean secret-scan over the full history", "two builds of one commit producing the
same artifact", "the pre-commit hook and the CI job, each observed to fail on a planted secret" --
because a rule whose evidence is easy to produce was not worth stating.

70 rules: 13 in 4.1, 6 in 4.2, 45 in 4.3, 6 in 4.4. Contiguous from 1 in each section, no gaps, which
the guard confirms rather than my reading of it.

The SSDF practice IDs stay in the requirement text in bold. They are stable names owned by NIST, they
are cited from outside this document, and replacing them would repeat the defect being fixed.

One rule gained a clause it did not have. SDS-4.3.36 (peer review) now names A.6 as where a project
without a human second reviewer records the deviation. That was already true -- A.6 carries exactly
that entry -- but the requirement did not point at it, so the rule read as unmet rather than deviated.
…1 checkable obligations

Section 7.4 is the section an adopter is most likely to be audited against, and it was the section
least able to answer "did you do it". Each tier was a bullet holding four to seven obligations joined
by semicolons -- validate the chain, check revocation, rotate before expiry -- so a connection could
satisfy two of them and still be described by the same sentence.

Split to one obligation per row with the evidence that settles each. 31 rules.

Where a bullet mixed a hard requirement with a preference, the two are now separate rules rather than
one sentence a reader has to parse for force. mTLS running over TLS 1.2 or later is SDS-7.4.3 and is
MUST; preferring 1.3 is SDS-7.4.4 and is SHOULD. The same split applies to the service account and its
gMSA preference, and to Kerberos and its preference over NTLM. Reading those as a single obligation
was how "prefer" quietly became "required" in one direction and "optional" in the other.

Two rules point at A.6 rather than pretending the project meets them: federated operator SSO
(SDS-7.4.25) is deferred and already carries a deviation there. Naming the deviation in the rule means
the register and the requirement no longer describe the state independently.

The ASVS V6/V9/V10/V12 and HIPAA mappings stay in the section's prose. They are the crosswalk, not
requirements of this standard, and per the convention a crosswalk carries no identifiers.

Section numbers are unchanged, so the existing citations to 7.4 -- in A.6, in the applicability
profile, and in the sibling standards -- still resolve while the identifiers are being adopted.
… no name

Converts the three remaining obligation-list sections: internal testing (6.2), release gates (6.4),
open-source project security (8), and the evidence set (9). 28 rules, bringing the document to 129.

Section 6.4 is the sharp one. A production release "requires: passing automated checks, no unresolved
high/critical findings, current independent-review status (or a documented risk acceptance), and
updated evidence" -- four conditions in one sentence, so a release that met three of them was still
describable by it, and no gate had a name to be waived by. They are now four rules, and the section
says plainly that skipping one is a deviation recorded in A.6 rather than a call made at release time.

Two evidence cells are deliberately narrower than the obligation a reader would assume.

SDS-6.4.1 asks for the green run of the RELEASED COMMIT, not of its branch tip. Under squash-merge
those are different commits, and checking the wrong one is the mistake this project has already made
in other contexts.

SDS-6.2.4 and SDS-8.2 ask for a HISTORY-WIDE scan, not a scan of the tip. The requirement always said
"the full history is kept clean"; the evidence now says so too, because a tip scan passes trivially
against a secret that was committed and then removed.

Section 9's prose is untouched. Its attestation-posture paragraphs, the published-wording correction
of 2026-08-02, and the note on what the SSDF attestation is answerable to are argument and history,
not requirements -- and the convention says a rule is a table row. Only the evidence-set bullets
became rules.

Section 6.3 keeps its prose in this commit. It is mostly scope description -- the level model, the
access an assessor needs, the chapter list -- with two real obligations buried in it; those get inline
identifiers with the rest of the prose rules rather than forcing a table onto a scoping section.
…ve destroyed

Appendix A.6 cites "5.3, recommended" and 6.3, and neither had anything to cite. Both sections are
prose, so the deviations register was pointing at paragraphs while the tabled sections had gained
identifiers around it. This closes that, without turning the essays into rows.

WHY NOT TABLES HERE. Section 3's prose-review rules are persuasive only because of what surrounds
them: the 2026-07-30 audit that produced them, the eleven retractions across four parallel sessions
that named the instrument rule, the specific wrong sentences quoted verbatim. A table row asserting
"MUST confirm the instrument answers the question asked" carries none of that, and a reader with no
reason to believe it will not apply it. The evidence IS the argument.

So the guard grew a second definition shape rather than the document losing its first. An inline rule
is a bullet opening with a bolded identifier, and it still carries an *Evidence:* clause -- no rule
anywhere escapes naming what settles it. 145 rules now, 16 sections, no gaps, every one with evidence.

Two things the extension had to get right, both fired at deliberately:

A bolded identifier MID-SENTENCE stays a citation. Otherwise emphatically citing a rule would define a
second, evidence-free copy of it, and the duplicate check would fire on the citation rather than on
the defect.

An UNNUMBERED subheading no longer blanks the section context. "Reviewing security prose" and "A note
on claims and wording" sit inside section 3, and resetting the context there would have silently
skipped the mis-siting check for every prose rule -- a check reporting green over exactly the rules it
was extended to cover. That is the shape this whole module exists to prevent, and it was one line.

Six inline mutations fired at the real 145-rule corpus, each confirmed red, the file restored
byte-identically after each.

Section 3's heading text is unchanged. It is cited by name from the standing contract, from two
backlog files and from a decision record, and the appendix declares that future projects reuse these
headings -- renaming one breaks a stated contract, not merely a link.
…ich is what already broke

This is the point of the exercise. A.6 carries dated risk acceptances with a named owner and a
$25,000-$50,000 figure attached, and it identified the requirements it departs from as "(§6.3 / §6.4)",
"(§7.4)", "(§5.3, recommended)". Those are positions. The last renumbering of this document left ten
citations in the maintainer's private security documents pointing at the wrong requirement, and A.6 is
built out of exactly that construction.

Every entry now names its rule: SDS-4.3.36 for single-maintainer development, SDS-6.4.3 for the
independent review, SDS-7.4.25 for federated SSO, SDS-5.3.1 for traceability. A.6 contains no
positional citation of a requirement of this standard.

The convention now states the distinction that makes this decidable rather than a matter of taste.
"See §5" points at a place to read and a section number is the right name for that. "This deviates
from §6.4" names a requirement and must give the identifier. Navigational pointers are left alone
deliberately -- there are three left in the applicability profile, all naming whole sections.

A CI PIN FIRED, AND IT WAS RIGHT TO. tests/test_dast_claims.py pinned the A.6 row verbatim, including
its section numbers, because that row is the single source of record for whether anything independent
has run. Repointing the row broke the pin. That is the pin working: the anchor genuinely moved. It now
names SDS-6.4.3, so a future reorganisation cannot move it again, and the comment above it records
what happened rather than leaving a silent edit.

A STALE DEVIATION IS RETIRED. A.6 recorded R2 -- the executable dry-run gate -- as outstanding. It is
built: checks.py reads a per-fixture expected disposition from a .expect sidecar and asserts it, with
the older not-ERROR semantics retained where no expectation is declared. Verified against the code
rather than taken on report. Three places in this document stated it as not done; all three now agree
with checks.py, and A.7.2's R2 is marked BUILT rather than deleted, so the record shows it was met.

THE STANDING CONTRACT HAD THE DEFECT IT FORBIDS. CLAUDE.md introduced the prose-review rules with "The
three rules below are instances of it" above FOUR bullets -- the instrument rule was added later and
the count was not. That is precisely SDS-3.6, in the sentence introducing SDS-3.6. Replaced with an
unnumbered form, and each imperative now carries the identifier of the rule it duplicates, so the
deliberate duplication is traceable instead of merely asserted.
…t were wrong

A revision is when an identifier gets reused by accident, so the version history is load-bearing for
the convention this release introduces rather than decoration on it.

States plainly that section numbers did NOT change, because that is the question a reader holding an
older citation will have, and the answer decides whether their citation still resolves. It does.

Records the two corrections rather than absorbing them silently. A.6 listed the executable dry-run
gate as outstanding after it had shipped, and the standing contract counted four rules as three. Both
were found by writing the identifiers, not by reading the prose, which is the argument for having
done it.

Also states that no requirement was added, removed, weakened or strengthened. The diff is large enough
that a reader cannot verify that by inspection, and leaving it unsaid invites the assumption that a
mood conversion changed what is owed.
…ord rather than raise it

tests/test_cutover_slug_rot.py ratchets present-tense prose about a mirror or a private source repo:
the count may fall, never rise. One line of the new guard's docstring took it from 54 to 55.

The line was a true statement about a different artifact -- the website copy of this standard, not the
retired publish-pipeline mirror the ratchet was built for -- so it was a false positive of a new kind.
That is still not grounds to raise the ceiling. The module says NEVER raise it, and a ceiling lifted
once for a defensible reason is a ceiling that lifts again for a worse one.

Reworded instead, which improved it on two other axes. It no longer restates the measured divergence
between the published copy and this document: that figure lives in the finding raised against the
website repository, and restating a moving number in a test docstring is exactly the duplication
SDS-3.5 forbids -- the stale copy is the one that gets cited. What the docstring needs to say is
narrower and more durable: this guard cannot read that repository, nothing checks whether the copy
still matches, and a green run here says nothing about what is served publicly.

Back to 54 hits across 1519 tracked files, my line no longer among them, ceiling unchanged.

Found by the full suite rather than the targeted runs -- the doc-guard subset does not include this
module. Worth recording because the run that found it reported exit code 0: the command ended in a
pipe, so the shell returned tail's status, not pytest's. SDS-3.8, in the act of verifying SDS-3.8.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 7, 2026 18:26
@wshallwshall
wshallwshall merged commit 4cc84e1 into main Aug 7, 2026
37 checks passed
@wshallwshall
wshallwshall deleted the claude/handoff-sds-a3bc90 branch August 7, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant